Compare commits

...

34 Commits

Author SHA1 Message Date
Alex Turchyn 5e4f207844 improve checkboxes form 2023-07-31 00:48:51 +03:00
Alex Turchyn 1f29e624e7 remember signed in user session 2023-07-27 23:33:54 +03:00
Alex Turchyn 658e8a7bd6 fix missing field areas 2023-07-27 09:59:52 +03:00
Alex Turchyn 08c3b04511 fix builder when field areas key is undefined 2023-07-26 23:47:35 +03:00
Alex Turchyn 7779eb880d adjust preview buttons style 2023-07-26 23:33:05 +03:00
Alex Turchyn 21b9ca8ea5 detach areas on remove docuemnt 2023-07-26 23:17:24 +03:00
Alex Turchyn db7e3794d9 fix auto migration 2023-07-26 22:17:13 +03:00
Alex Turchyn df1d78897a add ability to replace documents 2023-07-26 22:01:16 +03:00
Alex Turchyn e1eb2da6f9 preserve template fields per submission 2023-07-26 09:58:56 +03:00
Alex Turchyn a92d1d82c1 #23 allow smtp configure without requiring smtp user 2023-07-24 00:53:12 +03:00
Alex Turchyn 2dcfc41365 update readme 2023-07-24 00:52:21 +03:00
Alex Turchyn 15cbdfb20c fix completed page 2023-07-22 20:44:20 +03:00
Alex Turchyn a2cf8fc5b5 fix checkboxes 2023-07-22 20:20:58 +03:00
Alex Turchyn f94ef3afdc adjust self-host landing 2023-07-22 16:06:43 +03:00
Alex Turchyn 9e3a9f0eeb fix submitter form when minimized 2023-07-22 14:31:13 +03:00
Alex Turchyn 1280572e6f update readme 2023-07-22 11:34:00 +03:00
Alex Turchyn cba2e84338 add signup link into demo 2023-07-22 01:30:51 +03:00
Alex Turchyn 17ca8f013a add 'Try in Cloud' to README 2023-07-19 23:41:55 +03:00
Alex Turchyn 96ef7ffaf7 add Render deployment button to README 2023-07-19 01:40:41 +03:00
Alex Turchyn 1bbcdb8073 fix modal scroll 2023-07-19 01:38:00 +03:00
Alex Turchyn da130c3f4f fix recipients added alert 2023-07-19 01:31:27 +03:00
Alex Turchyn c15f3ecd83 update Daisyui to v3.2.1 2023-07-18 01:24:05 +03:00
Alex Turchyn df0e0e6a1b remove internal scrolling in modals 2023-07-18 01:19:29 +03:00
Alex Turchyn 3d75443d6e adjust registration links 2023-07-17 22:54:54 +03:00
Alex Turchyn b9af384cf2 add ability to minimize form 2023-07-17 22:48:01 +03:00
Alex Turchyn c40a8e1a69 adjust registration for SaaS 2023-07-16 16:15:37 +03:00
Alex Turchyn 4c8a706906 do not show signed in alert 2023-07-16 15:20:26 +03:00
Alex Turchyn 4c8e38867a set bcrypt rounds to 10 2023-07-15 21:46:03 +03:00
Alex Turchyn 7ed1b71a6b add optional sidekiq for background jobs 2023-07-15 13:38:13 +03:00
Alex Turchyn 6f3fe1dd7b improve registration and add oauth 2023-07-15 13:38:12 +03:00
Alex Turchyn f7ef99a624 allow to take a photo of signature or type text 2023-07-14 00:23:44 +03:00
Alex Turchyn 03ad848d45 fix demo mode 2023-07-13 00:34:29 +03:00
Alex Turchyn 4ffd198505 adjust demo 2023-07-12 01:36:33 +03:00
Alex Turchyn d595e7d52b adjust readme 2023-07-10 21:15:59 +03:00
67 changed files with 1166 additions and 416 deletions
+3
View File
@@ -61,5 +61,8 @@ RSpec/ExampleLength:
Rails/I18nLocaleTexts:
Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController:
Enabled: false
+3
View File
@@ -16,6 +16,8 @@ gem 'image_processing'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
@@ -25,6 +27,7 @@ gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'ruby-vips'
gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'turbo-rails'
+40
View File
@@ -232,6 +232,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashie (5.0.0)
hexapdf (0.32.2)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.3)
@@ -282,6 +283,7 @@ GEM
minitest (5.18.1)
msgpack (1.7.1)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
@@ -298,7 +300,29 @@ GEM
nio4r (2.5.9)
nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.15.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -326,6 +350,8 @@ GEM
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.7)
rack-protection (3.0.6)
rack
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
@@ -362,6 +388,8 @@ GEM
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
redis-client (0.14.1)
connection_pool
regexp_parser (2.8.1)
reline (0.3.5)
io-console (~> 0.5)
@@ -432,6 +460,11 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (7.1.2)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
@@ -444,6 +477,9 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.5.4)
mini_portile2 (~> 2.8.0)
strip_attributes (1.13.0)
@@ -462,6 +498,7 @@ GEM
uber (0.1.0)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
@@ -507,6 +544,8 @@ DEPENDENCIES
lograge
mysql2
oj
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy
pg
premailer-rails
@@ -522,6 +561,7 @@ DEPENDENCIES
rubocop-rspec
ruby-vips
shakapacker
sidekiq
simplecov
sqlite3
strip_attributes
+15 -4
View File
@@ -26,6 +26,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
</p>
<h2 align="center">
<a href="https://demo.docuseal.co">✨ Live Demo</a>
<span>|</span>
<a href="https://docuseal.co/sign_up">☁️ Try in Cloud</a>
</h2>
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
@@ -44,9 +46,11 @@ DocuSeal is an open source platform that provides secure and efficient digital d
## Deploy
| Heroku | Railway | DigitalOcean |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) |
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
#### Docker
@@ -64,11 +68,18 @@ Download docker-compose.yml into your private server:
curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml
```
Run docker compose under a custom domain over https (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
Run the app under a custom domain over https using docker compose (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
```sh
HOST=your-domain-name.com docker-compose up
```
## For Companies
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[![Book an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal)
## License
DocuSeal is released under the GNU Affero General Public License v3.0.
+2
View File
@@ -40,6 +40,8 @@ class AccountsController < ApplicationController
end
def app_url_params
return {} if params[:encrypted_config].blank?
params.require(:encrypted_config).permit(:value)
end
end
@@ -3,6 +3,8 @@
module Api
class TemplatesDocumentsController < ApiBaseController
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
@template = current_account.templates.find(params[:template_id])
documents =
+1 -1
View File
@@ -23,7 +23,7 @@ class ApplicationController < ActionController::Base
private
def sign_in_for_demo
sign_in(User.first) unless signed_in?
sign_in(User.order('random()').take) unless signed_in?
end
def current_account
+1
View File
@@ -4,6 +4,7 @@ class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index]
def index
return redirect_to Docuseal::PRODUCT_URL, allow_other_host: true if Docuseal.multitenant? && !signed_in?
return render 'pages/landing' unless signed_in?
templates = current_account.templates.active.preload(:author).order(id: :desc)
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end
+22 -2
View File
@@ -1,24 +1,44 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
def show; end
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private
def require_no_authentication
super
flash.clear
end
def build_resource(_hash = {})
account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params)
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password)
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs|
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone)
params.require(:account).permit(:name, :timezone).compact_blank
end
end
+20
View File
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
super
end
private
def require_no_authentication
super
flash.clear
end
end
+4 -2
View File
@@ -22,14 +22,16 @@ class SubmissionsController < ApplicationController
create_submissions_from_submitters
end
submitters = submissions.flat_map(&:submitters)
if params[:send_email] == '1'
submissions.flat_map(&:submitters).each do |submitter|
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
redirect_to template_path(@template),
notice: "#{submissions.size} #{'recipient'.pluralize(submissions.size)} added"
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
end
def destroy
@@ -23,6 +23,10 @@ class SubmitFormController < ApplicationController
submitter.save!
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank?
submitter.submission.save!
if submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
+1 -1
View File
@@ -13,7 +13,7 @@ export default actionable(class extends HTMLElement {
document.body.classList.remove('overflow-hidden')
document.removeEventListener('keyup', this.onEscKey)
document.removeEventListener('turbo:submit-end', this.handleSubmit)
document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('turbo:before-cache', this.close)
}
+1
View File
@@ -14,6 +14,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
isDemo: this.dataset.isDemo === 'true',
values: reactive(JSON.parse(this.dataset.values)),
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
+8 -3
View File
@@ -21,14 +21,14 @@
</span>
</div>
<div
v-if="isActive"
v-if="isActive && withLabel"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
>
{{ field.name || fieldNames[field.type] }}
<template v-if="field.type === 'checkbox'">
<template v-if="field.type === 'checkbox' && !field.name">
{{ fieldIndex + 1 }}
</template>
<template v-else-if="!field.required">
<template v-else-if="!field.required && field.type !== 'checkbox'">
(optional)
</template>
</div>
@@ -146,6 +146,11 @@ export default {
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
fieldIndex: {
type: Number,
required: false,
+9 -1
View File
@@ -11,7 +11,9 @@
v-for="(area, areaIndex) in field.areas"
:key="areaIndex"
>
<Teleport :to="`#page-${area.attachment_uuid}-${area.page}`">
<Teleport
:to="`#page-${area.attachment_uuid}-${area.page}`"
>
<FieldArea
:ref="setAreaRef"
v-model="values[field.uuid]"
@@ -20,6 +22,7 @@
:submittable="true"
:field-index="fieldIndex"
:is-active="currentStep === step"
:with-label="withLabel"
:is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="$emit('focus-step', step)"
@@ -54,6 +57,11 @@ export default {
required: false,
default: () => ({})
},
withLabel: {
type: Boolean,
required: false,
default: true
},
currentStep: {
type: Array,
required: false,
+30 -2
View File
@@ -12,7 +12,7 @@
</p>
<div class="space-y-3 mt-5">
<button
v-if="canSendEmail"
v-if="canSendEmail && !isDemo"
class="white-button flex items-center space-x-1 w-full"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
@@ -40,6 +40,27 @@
Download
</span>
</button>
<a
v-if="isDemo"
target="_blank"
href="https://github.com/docusealco/docuseal"
class="white-button flex items-center space-x-1 w-full"
>
<IconBrandGithub />
<span>
Star on Github
</span>
</a>
<a
v-if="isDemo"
href="https://docuseal.co/sign_up"
class="white-button flex items-center space-x-1 w-full"
>
<IconLogin />
<span>
Create a Free Account
</span>
</a>
</div>
<div class="text-center mt-4">
Signed with
@@ -53,14 +74,16 @@
</template>
<script>
import { IconCircleCheck, IconMail, IconDownload, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue'
export default {
name: 'FormCompleted',
components: {
IconCircleCheck,
IconInnerShadowTop,
IconBrandGithub,
IconMail,
IconLogin,
IconDownload
},
props: {
@@ -68,6 +91,11 @@ export default {
type: String,
required: true
},
isDemo: {
type: Boolean,
required: false,
default: false
},
canSendEmail: {
type: Boolean,
required: false,
+280 -231
View File
@@ -4,261 +4,299 @@
:steps="stepFields"
:values="values"
:attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes"
:current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true)]"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
/>
<div>
<form
<button
v-if="!isFormVisible"
class="btn btn-neutral text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true"
>
Submit Form
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
:height="20"
/>
</button>
<div
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
>
<button
v-if="!isCompleted"
ref="form"
:action="submitPath"
method="post"
class="md:mx-16"
@submit.prevent="submitStep"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
title="Minimize"
@click.prevent="isFormVisible = false"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
<div>
<form
v-if="!isCompleted"
ref="form"
:action="submitPath"
method="post"
class="md:mx-16"
@submit.prevent="submitStep"
>
<input
value="put"
name="_method"
type="hidden"
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div>
<input
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
>
<input
value="put"
name="_method"
type="hidden"
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div>
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
type="text"
class="select base-input !text-2xl w-full text-center font-normal"
:name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid"
:required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal"
:name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)"
>
<option
value=""
:selected="!values[currentField.uuid]"
>
Select your option
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
<option
value=""
:selected="!values[currentField.uuid]"
>
Select your option
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
>
<label
:for="currentField.uuid + option"
class="flex items-center space-x-3"
{{ option }}
</option>
</select>
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options"
:key="index"
>
<input
:id="currentField.uuid + option"
v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
<label
:for="currentField.uuid + option"
class="flex items-center space-x-3"
>
<span class="text-xl">
{{ option }}
</span>
</label>
<input
:id="currentField.uuid + option"
v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
>
<span class="text-xl">
{{ option }}
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<MultiSelectStep
v-else-if="currentField.type === 'multiple'"
v-model="values[currentField.uuid]"
:field="currentField"
/>
<div
v-else-if="currentField.type === 'checkbox'"
class="flex w-full"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<MultiSelectStep
v-else-if="currentField.type === 'multiple'"
v-model="values[currentField.uuid]"
:field="currentField"
/>
<div
class="space-y-3.5 mx-auto"
v-else-if="currentField.type === 'checkbox'"
class="flex w-full max-h-44 overflow-y-auto"
>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
<input
type="hidden"
name="cast_boolean"
value="true"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
<div
class="space-y-3.5 mx-auto"
>
<template v-if="isAnonymousChecboxes">
Complete hightlighted checkboxes and click <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? 'submit' : 'next' }}</span>.
</template>
<template v-else>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span class="text-xl">
{{ currentField.name || currentField.type + ' ' + (index + 1) }}
</span>
</label>
<label
:for="field.uuid"
class="flex items-center space-x-3"
>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span class="text-xl">
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div>
</div>
<ImageStep
v-else-if="currentField.type === 'image'"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
</div>
<ImageStep
v-else-if="currentField.type === 'image'"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
</div>
<div class="mt-6 md:mt-8">
<button
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
<div class="mt-6 md:mt-8">
<button
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
</span>
<span v-else>
Next
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
<span v-else>
Next
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
<FormCompleted
v-else
:can-send-email="canSendEmail"
:submitter-slug="submitterSlug"
/>
<div class="flex justify-center">
<div class="flex items-center mt-5 mb-1">
<a
v-for="(step, index) in stepFields"
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</button>
</div>
</form>
<FormCompleted
v-else
:is-demo="isDemo"
:can-send-email="canSendEmail"
:submitter-slug="submitterSlug"
/>
<div class="flex justify-center">
<div class="flex items-center mt-5 mb-1">
<a
v-for="(step, index) in stepFields"
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</div>
</div>
</div>
</div>
@@ -271,7 +309,7 @@ import SignatureStep from './signature_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import FormCompleted from './completed'
import { IconInnerShadowTop } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
export default {
name: 'SubmissionForm',
@@ -282,6 +320,8 @@ export default {
AttachmentStep,
MultiSelectStep,
IconInnerShadowTop,
IconArrowsDiagonal,
IconArrowsDiagonalMinimize2,
FormCompleted
},
props: {
@@ -317,6 +357,11 @@ export default {
required: true,
default: false
},
isDemo: {
type: Boolean,
required: true,
default: false
},
values: {
type: Object,
required: false,
@@ -326,6 +371,7 @@ export default {
data () {
return {
isCompleted: false,
isFormVisible: true,
currentStep: 0,
isSubmitting: false
}
@@ -334,6 +380,9 @@ export default {
currentStepFields () {
return this.stepFields[this.currentStep]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
isButtonDisabled () {
return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
@@ -390,7 +439,7 @@ export default {
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
if (clickUpload && !this.values[this.currentField.uuid]) {
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
this.$refs.form.querySelector('input[type="file"]')?.click()
}
})
+206 -23
View File
@@ -4,22 +4,62 @@
<label
class="label text-2xl"
>{{ field.name || 'Signature' }}</label>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
Redraw
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
Clear
</button>
<div class="space-x-2 flex">
<span
class="tooltip"
data-tip="Type text"
>
<button
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</button>
</span>
<span
class="tooltip"
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm btn-circle"
>
<IconCamera :width="16" />
<input
type="file"
hidden
accept="image/*"
@change="drawImage"
>
</label>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
Redraw
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
Clear
</button>
<button
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
</div>
</div>
<input
:value="modelValue"
@@ -29,23 +69,35 @@
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
class="w-full bg-white border border-base-300 rounded"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="isTextSignature"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:placeholder="`Type signature here...`"
type="text"
@input="updateWrittenSignature"
>
</div>
</template>
<script>
import { IconReload } from '@tabler/icons-vue'
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
export default {
name: 'SignatureStep',
components: {
IconReload
IconReload,
IconCamera,
IconTextSize,
IconArrowsDiagonalMinimize2
},
props: {
field: {
@@ -72,10 +124,11 @@ export default {
default: ''
}
},
emits: ['attached', 'update:model-value'],
emits: ['attached', 'update:model-value', 'start', 'minimize'],
data () {
return {
isSignatureStarted: false
isSignatureStarted: false,
isTextSignature: false
}
},
async mounted () {
@@ -94,6 +147,8 @@ export default {
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.$emit('start')
})
},
methods: {
@@ -104,6 +159,134 @@ export default {
this.pad.clear()
this.isSignatureStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenSignature (e) {
this.isSignatureStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.isTextSignature = !this.isTextSignature
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
drawImage (event) {
this.remove()
this.isSignatureStarted = true
const file = event.target.files[0]
if (file && file.type.match('image.*')) {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.src = event.target.result
img.onload = () => {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
let targetWidth = canvas.width
let targetHeight = canvas.height
if (canvas.width / canvas.height > aspectRatio) {
targetWidth = canvas.height * aspectRatio
} else {
targetHeight = canvas.width / aspectRatio
}
if (targetHeight > targetWidth) {
const scale = targetHeight / targetWidth
targetWidth = targetWidth * scale
targetHeight = targetHeight * scale
}
const x = (canvas.width - targetWidth) / 2
const y = (canvas.height - targetHeight) / 2
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start')
}
}
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 () {
if (this.modelValue) {
@@ -111,7 +294,7 @@ export default {
}
return new Promise((resolve) => {
this.$refs.canvas.toBlob(async (blob) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) {
@@ -153,7 +336,7 @@ export default {
return resolve(attachment)
})
}
}, 'image/png')
})
})
}
}
+1 -1
View File
@@ -65,7 +65,7 @@
@blur="onNameBlur"
>{{ field.name || defaultName }}</span>
<div
v-if="isNameFocus"
v-if="isNameFocus && field.type !== 'checkbox'"
class="flex items-center ml-1.5"
>
<input
+28 -4
View File
@@ -64,8 +64,11 @@
:with-arrows="template.schema.length > 1"
:item="item"
:document="sortedDocuments[index]"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@down="moveDocument(item, 1)"
@change="save"
@@ -133,7 +136,7 @@
<p>
<button
class="base-button"
@click="drawField = false"
@click="drawField = null"
>
Cancel
</button>
@@ -314,7 +317,7 @@ export default {
const field = {
name: '',
uuid: v4(),
required: true,
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
@@ -333,7 +336,7 @@ export default {
type: this.dragFieldType,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
required: true
required: this.dragFieldType !== 'checkbox'
}
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
@@ -353,7 +356,7 @@ export default {
if (this.selectedField?.type === this.dragFieldType) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas) {
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(this.dragFieldType)) {
@@ -417,6 +420,27 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
}
})
})
this.save()
},
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
this.template.documents.push(...documents)
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
this.save()
},
moveDocument (item, direction) {
@@ -93,6 +93,11 @@ export default {
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
+15 -13
View File
@@ -31,19 +31,21 @@
v-if="isNameFocus"
class="flex items-center relative"
>
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
<template v-if="field.type !== 'checkbox'">
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</template>
</div>
<div
v-else
+4 -3
View File
@@ -71,7 +71,7 @@
v-if="fields.length < 4"
class="text-xs p-2 border border-base-200 rounded"
>
<ui>
<ul class="list-disc list-outside ml-3">
<li>
Draw a text field on the page with a mouse
</li>
@@ -84,7 +84,7 @@
<li>
Click on the field type above to add it to the form without drawing it on the document
</li>
</ui>
</ul>
</div>
</template>
@@ -171,7 +171,8 @@ export default {
const field = {
name: '',
uuid: v4(),
required: true,
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
+59 -29
View File
@@ -9,39 +9,51 @@
loading="lazy"
>
<div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute"
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
@click="$emit('scroll-to', item)"
>
<div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
>
<div class="rounded-bl rounded-tr bg-white border">
<button
class="rounded-bl rounded-tr hover:text-base-100 hover:bg-base-content w-full transition-colors"
style="width: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
<div class="flex justify-between w-full">
<div style="width: 26px" />
<div class="">
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
class="opacity-0 group-hover:opacity-100"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
</div>
<div
v-if="withArrows"
class="flex flex-col border rounded-br rounded-tl bg-white divide-y"
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
>
<button
class="rounded-tl hover:text-base-100 hover:bg-base-content w-full transition-colors"
style="width: 24px"
@click.stop="$emit('up', item)"
<div>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
<div
v-if="withArrows"
class="flex flex-col space-y-1"
>
&uarr;
</button>
<button
class="rounded-br hover:text-base-100 hover:bg-base-content w-full transition-colors"
style="width: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
</div>
</div>
</div>
</div>
@@ -60,34 +72,52 @@
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default {
name: 'DocumentPreview',
components: {
Contenteditable
Contenteditable,
ReplaceButton
},
props: {
item: {
type: Object,
required: true
},
template: {
type: Object,
required: true
},
document: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: {
type: Boolean,
required: false,
default: true
}
},
emits: ['scroll-to', 'change', 'remove', 'up', 'down'],
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
computed: {
previewImage () {
return this.document.preview_images[0]
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
this.item.name = value
@@ -0,0 +1,70 @@
<template>
<label
:for="inputId"
class="btn btn-neutral btn-xs text-white transition-none"
:class="{ 'opacity-100': isLoading || isProcessing }"
>
{{ message }}
<form
ref="form"
class="hidden"
>
<input
:id="inputId"
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
@change="upload"
>
</form>
</label>
</template>
<script>
import Upload from './upload'
export default {
name: 'ReplaceDocument',
props: {
templateId: {
type: [Number, String],
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
data () {
return {
isLoading: false,
isProcessing: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
message () {
if (this.isLoading) {
return 'Uploading...'
} else if (this.isProcessing) {
return 'Processing...'
} else {
return 'Replace'
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload
}
}
</script>
+13 -6
View File
@@ -4,12 +4,15 @@
#
# Table name: submissions
#
# id :bigint not null, primary key
# deleted_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# created_by_user_id :bigint
# template_id :bigint not null
# id :bigint not null, primary key
# deleted_at :datetime
# template_fields :text
# template_schema :text
# template_submitters :text
# created_at :datetime not null
# updated_at :datetime not null
# created_by_user_id :bigint
# template_id :bigint not null
#
# Indexes
#
@@ -27,5 +30,9 @@ class Submission < ApplicationRecord
has_many :submitters, dependent: :destroy
serialize :template_fields, JSON
serialize :template_schema, JSON
serialize :template_submitters, JSON
scope :active, -> { where(deleted_at: nil) }
end
+1 -1
View File
@@ -32,7 +32,7 @@ class Submitter < ApplicationRecord
belongs_to :submission
attribute :values, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(10) }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :values, JSON
+4 -1
View File
@@ -36,7 +36,7 @@ class Template < ApplicationRecord
attribute :fields, :string, default: -> { [] }
attribute :schema, :string, default: -> { [] }
attribute :submitters, :string, default: -> { [{ name: DEFAULT_SUBMITTER_NAME, uuid: SecureRandom.uuid }] }
attribute :slug, :string, default: -> { SecureRandom.base58(10) }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :fields, JSON
serialize :schema, JSON
@@ -44,6 +44,9 @@ class Template < ApplicationRecord
has_many_attached :documents
has_many :schema_documents, ->(e) { where(uuid: e.schema.pluck('attachment_uuid')) },
class_name: 'ActiveStorage::Attachment', dependent: :destroy, as: :record, inverse_of: :record
has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) }
+5 -1
View File
@@ -46,7 +46,7 @@ class User < ApplicationRecord
belongs_to :account
devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable if Docuseal.multitenant?
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: 'admin'
@@ -56,6 +56,10 @@ class User < ApplicationRecord
!deleted_at?
end
def remember_me
true
end
def initials
[first_name.first, last_name.first].join.upcase
end
+1
View File
@@ -1,3 +1,4 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @templates.any? %>
<div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold">Templates</h1>
+7 -7
View File
@@ -1,6 +1,6 @@
<div class="max-w-xl mx-auto px-2">
<h1 class="text-4xl font-bold text-center my-8">Sign up</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, url: registration_path) do |f| %>
<h1 class="text-3xl font-bold text-center my-8">Profile Details</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, url: new_registration_path) do |f| %>
<div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %>
@@ -16,7 +16,7 @@
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control">
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>">
<%= ff.label :email, class: 'label' %>
<%= ff.email_field :email, required: true, class: 'base-input' %>
</div>
@@ -25,14 +25,14 @@
<set-timezone data-input-id="_account_timezone"></set-timezone>
<%= ff.hidden_field :timezone %>
<div class="form-control">
<%= ff.label :name, 'Company name', class: 'label' %>
<%= ff.text_field :name, required: true, class: 'base-input' %>
<%= ff.label :name, 'Company name (optional)', class: 'label' %>
<%= ff.text_field :name, class: 'base-input' %>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control">
<div class="form-control <%= 'hidden' if params[:oauth_callback] %>">
<%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, required: true, class: 'base-input' %>
<%= ff.password_field :password, required: !params[:oauth_callback], class: 'base-input' %>
</div>
<% end %>
</div>
@@ -0,0 +1,18 @@
<div class="max-w-xl mx-auto px-2">
<h1 class="text-3xl font-bold text-center my-8">Create Free Account</h1>
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, method: :get) do |f| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %>
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Sign up'), name: 'sign_up', value: true, class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+3 -8
View File
@@ -10,18 +10,13 @@
<%= f.label :password, class: 'label' %>
<%= f.password_field :password, autocomplete: 'current-password', class: 'base-input' %>
</div>
<% if devise_mapping.rememberable? %>
<div class="form-control">
<%= f.label :remember_me, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :remember_me, checked: true, class: 'base-checkbox' %>
<span class="label">Remember me</span>
<% end %>
</div>
<% end %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Log in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+1 -6
View File
@@ -3,7 +3,7 @@
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Sign up', new_registration_path, class: 'link link-hover' %>
<%= link_to 'Create free account', registration_path, class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
@@ -14,9 +14,4 @@
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), class: 'link link-hover', data: { turbo: false } %>
<% end %>
<% end %>
</div>
+16
View File
@@ -0,0 +1,16 @@
<svg width="800" height="800" viewBox="-0.5 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>">
<g id="Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Color-" transform="translate(-401.000000, -860.000000)">
<g id="Google" transform="translate(401.000000, 860.000000)">
<path d="M9.82727273,24 C9.82727273,22.4757333 10.0804318,21.0144 10.5322727,19.6437333 L2.62345455,13.6042667 C1.08206818,16.7338667 0.213636364,20.2602667 0.213636364,24 C0.213636364,27.7365333 1.081,31.2608 2.62025,34.3882667 L10.5247955,28.3370667 C10.0772273,26.9728 9.82727273,25.5168 9.82727273,24" id="Fill-1" fill="#FBBC05">
</path>
<path d="M23.7136364,10.1333333 C27.025,10.1333333 30.0159091,11.3066667 32.3659091,13.2266667 L39.2022727,6.4 C35.0363636,2.77333333 29.6954545,0.533333333 23.7136364,0.533333333 C14.4268636,0.533333333 6.44540909,5.84426667 2.62345455,13.6042667 L10.5322727,19.6437333 C12.3545909,14.112 17.5491591,10.1333333 23.7136364,10.1333333" id="Fill-2" fill="#EB4335">
</path>
<path d="M23.7136364,37.8666667 C17.5491591,37.8666667 12.3545909,33.888 10.5322727,28.3562667 L2.62345455,34.3946667 C6.44540909,42.1557333 14.4268636,47.4666667 23.7136364,47.4666667 C29.4455,47.4666667 34.9177955,45.4314667 39.0249545,41.6181333 L31.5177727,35.8144 C29.3995682,37.1488 26.7323182,37.8666667 23.7136364,37.8666667" id="Fill-3" fill="#34A853">
</path>
<path d="M46.1454545,24 C46.1454545,22.6133333 45.9318182,21.12 45.6113636,19.7333333 L23.7136364,19.7333333 L23.7136364,28.8 L36.3181818,28.8 C35.6879545,31.8912 33.9724545,34.2677333 31.5177727,35.8144 L39.0249545,41.6181333 C43.3393409,37.6138667 46.1454545,31.6490667 46.1454545,24" id="Fill-4" fill="#4285F4">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

-1
View File
@@ -15,7 +15,6 @@
<body>
<turbo-frame id="modal"></turbo-frame>
<%= render 'shared/navbar' %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %>
+3 -2
View File
@@ -8,7 +8,7 @@
DocuSeal
</h1>
</div>
<h2 class="mb-16 text-lg text-center text-gray-500">
<h2 class="mb-16 text-lg text-center text-gray-600">
A self-hosted and open-source web platform that provides secure and efficient digital document signing and processing.
</h2>
</div>
@@ -22,7 +22,7 @@
</div>
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
<p class="text-base text-gray-500">
Run on your own host using <a href="https://hub.docker.com/r/docuseal/docuseal" class="link link-neutral font-bold" target="_blank">Docker</a> container, or deploy on your favorite managed PaaS with a single <a href="https:///www.docuseal.co/install" class="link link-neutral font-bold">click</a>.
Run on your own host using <a href="https://hub.docker.com/r/docuseal/docuseal" class="link link-neutral font-bold" target="_blank">Docker</a> container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.co/install" class="link link-neutral font-bold">click</a>.
</p>
</div>
</div>
@@ -70,3 +70,4 @@
</div>
</div>
</section>
<%= render 'shared/attribution' %>
+8 -6
View File
@@ -1,9 +1,11 @@
<div class="absolute top-0 w-full h-0">
<div class="max-w-xl mx-auto mt-1.5">
<div class="px-4 py-3 rounded-2xl flex items-center justify-between mx-4 md:mx-0">
<a target="_blank" href="<%= start_form_url(slug: ::Template.first&.slug) %>" class="font-medium w-full text-center underline">
Demo Environment
</a>
<div class="mx-auto mt-1.5">
<div class="py-3 rounded-2xl flex items-center justify-between mx-4 md:mx-0">
<div class="w-full text-center">
<span class="font-bold">Demo Environment</span>
<br>
Feel free to
<a href="<%= new_template_path %>" data-turbo-frame="modal" class="inline underline font-medium">create a new template</a> document form or
<a href="<%= start_form_url(slug: ::Template.first&.slug) %>" target="_blank" class="inline underline font-medium">submit the existing one</a> 😊
</div>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
<a href="https://github.com/docusealco/docuseal" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" target="_blank" alt="Star on GitHub" style="height: 37px">
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</span>
<span class="flex">
<span class="hidden lg:block">Star on&nbsp</span>GitHub
</span>
</a>
+29 -13
View File
@@ -4,13 +4,19 @@
<%= render 'shared/logo' %>
<span>DocuSeal</span>
</a>
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.co/github-badge.svg" style="height: 22px">
</a>
<% unless Docuseal.demo? %>
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.co/github-badge.svg" style="height: 22px">
</a>
<% end %>
</div>
<% if signed_in? %>
<div class="space-x-6">
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline font-medium text-lg' %>
<div class="space-x-4 flex items-center">
<% if Docuseal.demo? %>
<%= render 'shared/github_button' %>
<% else %>
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline font-medium text-lg mr-2' %>
<% end %>
<div class="dropdown dropdown-end">
<label tabindex="0" class="cursor-pointer bg-base-content text-purple-300 rounded-full p-2 w-9 justify-center flex">
<span class="text-sm align-text-top"><%= current_user.initials %></span>
@@ -23,8 +29,8 @@
<% end %>
</li>
<li>
<%= link_to destroy_user_session_path, data: { turbo_method: :delete } do %>
<%= svg_icon('logout', class: 'w-5 h-5 stroke-2') %>
<%= button_to destroy_user_session_path, method: :delete, data: { turbo: false } do %>
<%= svg_icon('logout', class: 'w-5 h-5 stroke-2 inline') %>
<span class="mr-1">Sign out</span>
<% end %>
</li>
@@ -32,11 +38,21 @@
</div>
</div>
<% else %>
<%= link_to new_user_session_path, class: 'font-medium text-lg' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('login', class: 'w-6 h-6') %>
<span>Sign in</span>
</span>
<% end %>
<div class="flex space-x-6">
<%= link_to new_user_session_path, class: 'font-medium text-lg' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('login', class: 'w-6 h-6') %>
<span>Sign in</span>
</span>
<% end %>
<% if Docuseal.multitenant? %>
<%= link_to registration_path, class: 'font-medium text-lg hidden md:inline' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('user_plus', class: 'w-6 h-6') %>
<span>Try for Free</span>
</span>
<% end %>
<% end %>
</div>
<% end %>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<turbo-frame id="modal">
<turbo-modal class="modal modal-open items-start !animate-none">
<turbo-modal class="modal modal-open items-start !animate-none overflow-y-auto">
<div class="absolute top-0 bottom-0 right-0 left-0" data-action="click:turbo-modal#close"></div>
<div class="modal-box pt-4 pb-6 px-6 mt-20">
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
<% if local_assigns[:title] %>
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
+1 -1
View File
@@ -9,7 +9,7 @@
<h1 class="text-5xl font-bold text-center">DocuSeal</h1>
</a>
</div>
<div class="flex items-center bg-base-300 rounded-xl p-4 mb-4">
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
+3 -3
View File
@@ -2,7 +2,7 @@
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="text-center w-full space-y-6">
<a href="/" class="flex justify-center">
<a href="/" class="flex justify-center items-center">
<span class="mr-3">
<%= render 'shared/logo', width: '50px', height: '50px' %>
</span>
@@ -10,14 +10,14 @@
</a>
<p class="text-xl font-semibold text-center">You have been invited to submit a form</p>
</div>
<div class="flex items-center bg-base-300 rounded-xl p-4 mb-4">
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<p class="text-sm">Invited by <%= @template.account.name %></p>
<p class="text-sm">Invited by <span class="font-semibold"><%= @template.account.name %></span></p>
</div>
</div>
</div>
+6 -6
View File
@@ -23,7 +23,7 @@
</div>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5">
<% @submission.template.schema.each do |item| %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template.documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= document.preview_images.first.url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
@@ -35,10 +35,10 @@
</div>
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template) %>
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% @submission.template.schema.each do |item| %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template.documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
@@ -57,15 +57,15 @@
</div>
<div class="hidden md:block relative w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600] %>
<% submitter_fields_index = @submission.template.fields.group_by { |f| f['submitter_uuid'] } %>
<% @submission.template.submitters.each_with_index do |item, index| %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1 z-10">
<div class="border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center space-x-1">
<span class="mx-1 w-3 h-3 rounded-full <%= colors[index] %>"></span>
<span class="text-lg">
<%= @submission.template.submitters.find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
</div>
<div class="flex items-center space-x-1 mt-1">
+2 -2
View File
@@ -9,7 +9,7 @@
<h1 class="text-5xl font-bold text-center">DocuSeal</h1>
</a>
</div>
<div class="flex items-center bg-base-300 rounded-xl p-4 mb-4">
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
@@ -23,7 +23,7 @@
</div>
<div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<div class="divider">OR</div>
<% end %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
+3 -5
View File
@@ -1,4 +1,4 @@
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template) %>
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
<% values = @submitter.submission.submitters.where.not(id: @submitter.id).reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<div style="max-height: -webkit-fill-available;">
@@ -10,7 +10,7 @@
<span>DocuSeal</span>
</a>
</div>
<% @submitter.submission.template.schema.each do |item| %>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template.documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<div class="relative my-4 shadow-md">
@@ -32,9 +32,7 @@
<div class="fixed bottom-0 w-full h-0 z-20">
<div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32">
<div id="form_container" class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border border-base-200 border p-4 rounded">
<submission-form data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter-uuid="<%= @submitter.uuid %>" data-submitter-slug="<%= @submitter.slug %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" 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.select { |f| f['submitter_uuid'] == @submitter.uuid }.to_json %>" data-values="<%= @submitter.values.to_json %>" data-authenticity-token="<%= form_authenticity_token %>"></submission-form>
</div>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter-uuid="<%= @submitter.uuid %>" data-submitter-slug="<%= @submitter.slug %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" 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-values="<%= @submitter.values.to_json %>" data-authenticity-token="<%= form_authenticity_token %>"></submission-form>
</div>
</div>
</div>
+1 -1
View File
@@ -1 +1 @@
<template-builder data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template.to_json(include: { documents: { include: { preview_images: { methods: %i[url metadata filename] } } } }) %>"></template-builder>
<template-builder data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template.as_json.merge(documents: @template.schema_documents.as_json(include: { preview_images: { methods: %i[url metadata filename] } })).to_json %>"></template-builder>
+12 -6
View File
@@ -37,7 +37,7 @@
<div class="space-y-4">
<% @submissions.each do |submission| %>
<a href="<%= submission_path(submission) %>" class="bg-base-200 w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between rounded-2xl px-6 py-5 md:items-center">
<% submitters = submission.template.submitters.filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% is_submission_complated = submitters.all?(&:completed_at?) %>
<% if submitters.size == 1 %>
<div>
@@ -157,13 +157,19 @@
<% else %>
<div class="card bg-base-200">
<div class="card-body text-center py-16">
<div class="max-w-md mx-auto">
<div class="max-w-lg mx-auto">
<p class="text-3xl font-bold text-base-content mb-4">There are no Submissions</p>
<p class="text-gray-600">Send an invitation to fill and complete the form</p>
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Add Recipients</span>
<% end %>
<div class="space-x-2">
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Add Recipients</span>
<% end %>
<%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
<%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1">Submit it Yourself</span>
<% end %>
</div>
</div>
</div>
</div>
+2
View File
@@ -45,6 +45,8 @@ Rails.application.configure do
config.cache_store = :null_store
end
config.active_job.queue_adapter = :sidekiq if defined?(Sidekiq)
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :disk
config.active_storage.resolve_model_to_route = :rails_storage_proxy
+4 -2
View File
@@ -25,6 +25,8 @@ Rails.application.configure do
config.action_controller.perform_caching = true
config.active_record.sqlite3_production_warning = false
config.active_job.queue_adapter = :sidekiq if defined?(Sidekiq)
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
# config.require_master_key = true
@@ -74,7 +76,7 @@ Rails.application.configure do
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
config.action_mailer.raise_delivery_errors = false
if ENV['SMTP_USERNAME']
if ENV['SMTP_ADDRESS']
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV.fetch('SMTP_ADDRESS', nil),
@@ -84,7 +86,7 @@ Rails.application.configure do
password: ENV.fetch('SMTP_PASSWORD', nil),
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain'),
enable_starttls_auto: ENV['SMTP_ENABLE_STARTTLS_AUTO'] == 'true'
}
}.compact
end
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+3 -3
View File
@@ -120,7 +120,7 @@ Devise.setup do |config|
# a value less than 10 in other environments. Note that, for bcrypt (the default
# algorithm), the cost increases exponentially with the number of stretches (e.g.
# a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
config.stretches = Rails.env.test? ? 1 : 12
config.stretches = Rails.env.test? ? 1 : 10
# Set up a pepper to generate the hashed password.
# Send a notification to the original email when the user's email is changed.
@@ -159,7 +159,7 @@ Devise.setup do |config|
# ==> Configuration for :rememberable
# The time the user will be remembered without asking for credentials again.
# config.remember_for = 2.weeks
config.remember_for = 2.years
# Invalidates all the remember me tokens when the user signs out.
config.expire_all_remember_me_on_sign_out = true
@@ -266,7 +266,7 @@ Devise.setup do |config|
# ==> OmniAuth
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
# config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
+3 -1
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
ActiveRecord::Tasks::DatabaseTasks.migrate if ENV['RAILS_ENV'] == 'production'
Rails.configuration.to_prepare do
ActiveRecord::Tasks::DatabaseTasks.migrate if ENV['RAILS_ENV'] == 'production'
end
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
if defined?(Sidekiq)
require 'sidekiq/web'
Sidekiq::Web.use(Rack::Auth::Basic) do |_, password|
next true if Rails.env.development?
ActiveSupport::SecurityUtils.secure_compare(
Digest::SHA256.hexdigest(password),
Digest::SHA256.hexdigest(ENV.fetch('SIDEKIQ_BASIC_AUTH_PASSWORD'))
)
end
Sidekiq.strict_args!
end
+10 -2
View File
@@ -2,14 +2,22 @@
Rails.application.routes.draw do
mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?
mount Sidekiq::Web => '/sidekiq' if defined?(Sidekiq)
root 'dashboard#index'
devise_for :users, path: '/', only: %i[sessions passwords]
devise_for :users,
path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin
options = { sessions: 'sessions' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
options
end
devise_scope :user do
if Docuseal.multitenant?
resource :registration, only: %i[create], path: 'sign_up' do
resource :registration, only: %i[show], path: 'sign_up'
resource :registration, only: %i[create], path: 'new' do
get '' => :new, as: :new
end
end
+6
View File
@@ -0,0 +1,6 @@
queues:
- [default, 1]
- [mailers, 1]
production:
:concurrency: 15
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class AddTemplateFieldsToSubmission < ActiveRecord::Migration[7.0]
class MigrationTemplate < ApplicationRecord
self.table_name = 'templates'
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
add_column :submissions, :template_fields, :text
add_column :submissions, :template_schema, :text
add_column :submissions, :template_submitters, :text
MigrationTemplate.all.each do |template|
MigrationSubmission.where(template_id: template.id).each do |submission|
submission.update_columns(template_fields: template.fields,
template_schema: template.schema,
template_submitters: template.submitters)
end
end
end
def down
remove_column :submissions, :template_fields
remove_column :submissions, :template_schema
remove_column :submissions, :template_submitters
end
end
+4 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_07_01_075115) do
ActiveRecord::Schema[7.0].define(version: 2023_07_26_062428) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -77,6 +77,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_01_075115) do
t.datetime "deleted_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "template_fields"
t.text "template_schema"
t.text "template_submitters"
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["template_id"], name: "index_submissions_on_template_id"
end
+39
View File
@@ -3,6 +3,45 @@
module Accounts
module_function
def create_duplicate(account)
new_account = account.dup
new_user = account.users.first.dup
new_user.account = new_account
new_user.encrypted_password = SecureRandom.hex
new_user.email = "#{SecureRandom.hex}@docuseal.co"
account.templates.each do |template|
new_template = template.dup
new_template.account = new_account
new_template.slug = SecureRandom.base58(14)
new_template.save!
Templates::CloneAttachments.call(template: new_template, original_template: template)
end
new_user.save!(validate: false)
new_account
end
def create_default_template(account)
template = Template.find(1)
new_template = Template.find(1).dup
new_template.account_id = account.id
new_template.slug = SecureRandom.base58(14)
new_template.save!
Templates::CloneAttachments.call(template: new_template, original_template: template)
new_template
end
def load_signing_certs(account)
certs =
if Docuseal.multitenant?
+6
View File
@@ -4,6 +4,12 @@ module ActionMailerConfigsInterceptor
module_function
def delivering_email(message)
if Docuseal.demo?
message.delivery_method(:test)
return message
end
if Rails.env.production? && Rails.application.config.action_mailer.delivery_method
message.from = ENV.fetch('SMTP_FROM')
+9
View File
@@ -1,4 +1,13 @@
# frozen_string_literal: true
module Submissions
module_function
def update_template_fields!(submission)
submission.template_fields = submission.template.fields
submission.template_schema = submission.template.schema
submission.template_submitters = submission.template.submitters
submission.save!
end
end
@@ -27,7 +27,7 @@ module Submissions
pdfs_index = build_pdfs_index(submitter)
template.fields.each do |field|
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
field.fetch('areas', []).each do |area|
@@ -159,7 +159,7 @@ module Submissions
original_documents = template.documents.preload(:blob)
results =
template.schema.map do |item|
submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']]
attachment = save_signed_pdf(pdf:, submitter:, certs:, uuid: item['attachment_uuid'], name: item['name'])
+2 -2
View File
@@ -3,10 +3,10 @@
module Templates
module_function
def build_field_areas_index(template)
def build_field_areas_index(fields)
hash = {}
template.fields.each do |field|
fields.each do |field|
(field['areas'] || []).each do |area|
hash[area['attachment_uuid']] ||= {}
acc = (hash[area['attachment_uuid']][area['page']] ||= [])
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Users
module_function
def from_omniauth(oauth)
user = User.find_by(email: oauth.info.email)
return user if user
User.new(email: oauth.info.email,
first_name: oauth.extra.id_info.given_name,
last_name: oauth.extra.id_info.family_name)
end
end
+1 -1
View File
@@ -19,7 +19,7 @@
"compression-webpack-plugin": "10.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"daisyui": "^3.1.5",
"daisyui": "^3.2.1",
"mini-css-extract-plugin": "^2.7.5",
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
+14 -4
View File
@@ -2300,13 +2300,14 @@ csstype@^3.1.1:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
daisyui@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-3.1.5.tgz#49cb961d03504beebd238d58f0169dbe81103c95"
integrity sha512-3nk5L//IU31ISzsonKEFG8ris8hS/MRAc+PrOTOyRbssjv1fYmNKSdIrOhG6op2ED3RyJL4p6I81JUwd7RWqTw==
daisyui@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-3.2.1.tgz#7fac542b03023adbde2afc869518840dbef509ee"
integrity sha512-gIqE6wiqoJt9G8+n3R/SwLeUnpNCE2eDhT73rP0yZYVaM7o6zVcakBH3aEW5RGpx3UkonPiLuvcgxRcb2lE8TA==
dependencies:
colord "^2.9"
css-selector-tokenizer "^0.8"
postcss "^8"
postcss-js "^4"
tailwindcss "^3"
@@ -4448,6 +4449,15 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8:
version "8.4.26"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.26.tgz#1bc62ab19f8e1e5463d98cf74af39702a00a9e94"
integrity sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@^8.1.10, postcss@^8.4.21, postcss@^8.4.23:
version "8.4.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab"