mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c88715daec | ||
|
|
0375dad7c9 | ||
|
|
de3f83db96 | ||
|
|
a7bb96464c | ||
|
|
1d48fe9621 | ||
|
|
9544bb3d76 | ||
|
|
4e94664008 | ||
|
|
e41fe5fbca | ||
|
|
24d713dec6 | ||
|
|
f464e1c46a | ||
|
|
85d52d1f28 | ||
|
|
e77b5fa2c9 | ||
|
|
c91b4a765b | ||
|
|
2a031c14c8 | ||
|
|
f7dfc0b2ed | ||
|
|
3b894f5281 | ||
|
|
9645332e48 | ||
|
|
5670390b37 | ||
|
|
260bd6d5d5 | ||
|
|
fa0733f480 | ||
|
|
8b9678a434 | ||
|
|
43669bf095 | ||
|
|
2ef8df3f0c | ||
|
|
1c9d15aa7e | ||
|
|
9fcd8955d3 | ||
|
|
5e44ba49b8 | ||
|
|
ac1f90e711 | ||
|
|
c6eae66127 | ||
|
|
08aa902e07 | ||
|
|
1690337699 | ||
|
|
f78cf8cc6e | ||
|
|
343118d00d | ||
|
|
a3bd37c9a2 | ||
|
|
8bc13a5900 | ||
|
|
21138c1d02 | ||
|
|
38674a9fc9 | ||
|
|
a7ed0b86a4 | ||
|
|
91a96f06c8 | ||
|
|
23631b75f2 | ||
|
|
0fb9c2bbf6 | ||
|
|
14fa8fc8e7 | ||
|
|
047c192988 | ||
|
|
bfa244c8fa |
No files matched your search
@@ -157,7 +157,10 @@ jobs:
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install libvips
|
||||
sudo apt-get install -y libvips
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
|
||||
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
||||
rm -f pdfium-linux.tgz
|
||||
- name: Run
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
|
||||
+16
-5
@@ -1,8 +1,17 @@
|
||||
FROM ruby:3.4.2-alpine AS fonts
|
||||
FROM ruby:3.4.2-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
RUN apk --no-cache add fontforge wget && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
|
||||
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
|
||||
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
|
||||
|
||||
@@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
|
||||
\n\
|
||||
@@ -70,8 +79,10 @@ COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
||||
COPY .version ./public/version
|
||||
|
||||
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountConfigsController < ApplicationController
|
||||
before_action :load_account_config
|
||||
authorize_resource :account_config
|
||||
before_action :load_account_config, only: :create
|
||||
authorize_resource :account_config, only: :create
|
||||
|
||||
load_and_authorize_resource :account_config, only: :destroy
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
@@ -30,6 +32,14 @@ class AccountConfigsController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
|
||||
|
||||
@account_config.destroy!
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account_config
|
||||
|
||||
@@ -100,6 +100,7 @@ module Api
|
||||
permitted_params = [
|
||||
:name,
|
||||
:external_id,
|
||||
:shared_link,
|
||||
{
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
|
||||
@@ -55,6 +55,14 @@ class ApplicationController < ActionController::Base
|
||||
request.session[:impersonated_user_id] = user.uuid
|
||||
end
|
||||
|
||||
def pagy_auto(collection, **keyword_args)
|
||||
if current_ability.can?(:manage, :countless)
|
||||
pagy_countless(collection, **keyword_args)
|
||||
else
|
||||
pagy(collection, **keyword_args)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_locale(&)
|
||||
|
||||
@@ -9,7 +9,9 @@ class EmailSmtpSettingsController < ApplicationController
|
||||
|
||||
def create
|
||||
if @encrypted_config.update(email_configs)
|
||||
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
|
||||
unless Docuseal.multitenant?
|
||||
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
|
||||
end
|
||||
|
||||
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
|
||||
else
|
||||
|
||||
@@ -10,16 +10,17 @@ class SendSubmissionEmailController < ApplicationController
|
||||
SEND_DURATION = 30.minutes
|
||||
|
||||
def create
|
||||
@submitter =
|
||||
if params[:template_slug]
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
else
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
if params[:template_slug]
|
||||
@submitter = Submitter.completed.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
@submitter = Submitter.completed.joins(:submission).find_by(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
|
||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||
else
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
|
||||
@@ -8,20 +8,28 @@ class StartFormController < ApplicationController
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed]
|
||||
before_action :maybe_redirect_com, only: %i[show completed]
|
||||
before_action :load_resubmit_submitter, only: :update
|
||||
before_action :load_template
|
||||
before_action :authorize_start!, only: :update
|
||||
|
||||
def show
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] == true
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
|
||||
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(account_id: @template.account_id,
|
||||
uuid: (filter_undefined_submitters(@template).first ||
|
||||
@template.submitters.first)['uuid'])
|
||||
if @template.shared_link?
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(account_id: @template.account_id,
|
||||
uuid: (filter_undefined_submitters(@template).first ||
|
||||
@template.submitters.first)['uuid'])
|
||||
else
|
||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render :private if current_user && current_ability.can?(:read, @template)
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found')
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
@submitter = find_or_initialize_submitter(@template, submitter_params)
|
||||
|
||||
if @submitter.completed_at?
|
||||
@@ -59,6 +67,8 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def completed
|
||||
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions)
|
||||
.where.not(completed_at: nil)
|
||||
.find_by!(email: params[:email])
|
||||
@@ -66,6 +76,24 @@ class StartFormController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def load_resubmit_submitter
|
||||
@resubmit_submitter =
|
||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
||||
Submitter.find_by(slug: params[:resubmit])
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_start!
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
return if @resubmit_submitter
|
||||
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
|
||||
|
||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
redirect_to start_form_path(@template.slug)
|
||||
end
|
||||
|
||||
def enqueue_submission_create_webhooks(submitter)
|
||||
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
|
||||
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
|
||||
@@ -74,31 +102,29 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def find_or_initialize_submitter(template, submitter_params)
|
||||
Submitter.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
.where(ip: [nil, request.remote_ip])
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
|
||||
Submitter
|
||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
.where(ip: [nil, request.remote_ip])
|
||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
|
||||
end
|
||||
|
||||
def assign_submission_attributes(submitter, template)
|
||||
resubmit_submitter =
|
||||
(Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
|
||||
|
||||
submitter.assign_attributes(
|
||||
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent,
|
||||
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
||||
metadata: resubmit_submitter&.metadata.presence || {}
|
||||
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
||||
metadata: @resubmit_submitter&.metadata.presence || {}
|
||||
)
|
||||
|
||||
if submitter.values.present?
|
||||
resubmit_submitter.attachments.each do |attachment|
|
||||
@resubmit_submitter.attachments.each do |attachment|
|
||||
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
|
||||
end
|
||||
end
|
||||
@@ -120,15 +146,21 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
return current_user.slice(:email) if params[:selfsign]
|
||||
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
|
||||
|
||||
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
|
||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||
end
|
||||
end
|
||||
|
||||
def load_template
|
||||
slug = params[:slug] || params[:start_form_slug]
|
||||
|
||||
@template = Template.find_by!(slug:)
|
||||
@template =
|
||||
if @resubmit_submitter
|
||||
@resubmit_submitter.template
|
||||
else
|
||||
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
|
||||
end
|
||||
end
|
||||
|
||||
def multiple_submitters_error_message
|
||||
|
||||
@@ -18,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsPreviewController < ApplicationController
|
||||
around_action :with_browser_locale
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class TemplateFoldersController < ApplicationController
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit: 12)
|
||||
@pagy, @templates = pagy_auto(@templates, limit: 12)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
@@ -7,6 +7,6 @@ class TemplatesArchivedController < ApplicationController
|
||||
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit: 12)
|
||||
@pagy, @templates = pagy_auto(@templates, limit: 12)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
@submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ class TemplatesController < ApplicationController
|
||||
submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ class TemplatesDashboardController < ApplicationController
|
||||
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
items: FOLDERS_PER_PAGE,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ class TemplatesDashboardController < ApplicationController
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy(@templates, limit:)
|
||||
@pagy, @templates = pagy_auto(@templates, limit:)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:shared_link)
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
|
||||
import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
import ToggleOnSubmit from './elements/toggle_on_submit'
|
||||
import CheckOnClick from './elements/check_on_click'
|
||||
import PasswordInput from './elements/password_input'
|
||||
import SearchInput from './elements/search_input'
|
||||
import ToggleAttribute from './elements/toggle_attribute'
|
||||
@@ -103,6 +104,7 @@ safeRegisterElement('set-date-button', SetDateButton)
|
||||
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
|
||||
safeRegisterElement('app-tour', AppTour)
|
||||
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
|
||||
safeRegisterElement('check-on-click', CheckOnClick)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => {
|
||||
if (!this.element.checked) {
|
||||
this.element.checked = true
|
||||
this.element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get element () {
|
||||
return document.getElementById(this.dataset.elementId)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
|
||||
this.clearChecked()
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const text = this.dataset.text || this.innerText.trim()
|
||||
|
||||
if (navigator.clipboard) {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none"
|
||||
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none field-area-active-label"
|
||||
>
|
||||
<template v-if="area.option_uuid && !option.value">
|
||||
{{ optionValue(option) }}
|
||||
@@ -55,12 +55,12 @@
|
||||
>
|
||||
<div
|
||||
v-else-if="field.type === 'signature' && signature"
|
||||
class="flex justify-between h-full gap-1 overflow-hidden"
|
||||
:class="isNarrow ? 'flex-row' : 'flex-col'"
|
||||
class="flex justify-between h-full gap-1 overflow-hidden w-full"
|
||||
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
|
||||
>
|
||||
<div
|
||||
class="flex overflow-hidden"
|
||||
:class="isNarrow ? 'w-1/2' : 'flex-grow'"
|
||||
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
|
||||
style="min-height: 50%"
|
||||
>
|
||||
<img
|
||||
@@ -69,7 +69,7 @@
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="withSignatureId"
|
||||
v-if="withSignatureId || field.preferences?.reason_field_uuid"
|
||||
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
|
||||
:class="isNarrow ? 'w-1/2' : 'w-full'"
|
||||
>
|
||||
|
||||
@@ -211,7 +211,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
computedPreviousValue () {
|
||||
if (this.isUsePreviousValue) {
|
||||
if (this.isUsePreviousValue && this.field.required === true) {
|
||||
return this.previousValue
|
||||
} else {
|
||||
return null
|
||||
|
||||
@@ -414,7 +414,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computedPreviousValue () {
|
||||
if (this.isUsePreviousValue) {
|
||||
if (this.isUsePreviousValue && this.field.required === true) {
|
||||
return this.previousValue
|
||||
} else {
|
||||
return null
|
||||
|
||||
@@ -151,8 +151,10 @@ export default {
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
|
||||
}
|
||||
},
|
||||
toggleTextArea () {
|
||||
this.isTextArea = true
|
||||
|
||||
@@ -79,13 +79,52 @@
|
||||
name="buttons"
|
||||
/>
|
||||
<template v-else>
|
||||
<form
|
||||
v-if="withSignYourselfButton && template.submitters.length < 2"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="inline"
|
||||
method="post"
|
||||
:action="`/d/${template.slug}`"
|
||||
@submit="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="_method"
|
||||
value="put"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="selfsign"
|
||||
value="true"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||
type="submit"
|
||||
>
|
||||
<IconWritingSign
|
||||
width="22"
|
||||
class="inline"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('sign_yourself') }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
v-if="withSignYourselfButton"
|
||||
v-else-if="withSignYourselfButton"
|
||||
id="sign_yourself_button"
|
||||
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
|
||||
:href="`/templates/${template.id}/submissions/new?selfsign=true`"
|
||||
class="btn btn-primary btn-ghost text-base hidden md:flex"
|
||||
:target="template.submitters.length > 1 ? '' : '_blank'"
|
||||
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
|
||||
data-turbo-frame="modal"
|
||||
@click="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
<IconWritingSign
|
||||
@@ -1279,7 +1318,7 @@ export default {
|
||||
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
|
||||
}
|
||||
},
|
||||
onDraw (area) {
|
||||
onDraw ({ area, isTooSmall }) {
|
||||
if (this.drawField) {
|
||||
if (this.drawOption) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
@@ -1374,7 +1413,7 @@ export default {
|
||||
area.y -= area.h / 2
|
||||
}
|
||||
|
||||
if (area.w) {
|
||||
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
|
||||
this.addField(type, area)
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
|
||||
@remove-area="$emit('remove-area', $event)"
|
||||
@scroll-to="scrollToArea"
|
||||
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
|
||||
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -290,7 +290,12 @@ export default {
|
||||
area.cell_w = this.newArea.cell_w
|
||||
}
|
||||
|
||||
this.$emit('draw', area)
|
||||
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
|
||||
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
|
||||
|
||||
const isTooSmall = dx < 8 && dy < 8
|
||||
|
||||
this.$emit('draw', { area, isTooSmall })
|
||||
}
|
||||
|
||||
this.showMask = false
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
|
||||
end
|
||||
|
||||
def set_message_metadata
|
||||
message.instance_variable_set(:@message_metadata, @message_metadata)
|
||||
message.instance_variable_set(:@message_metadata, @message_metadata || {})
|
||||
end
|
||||
|
||||
def set_message_uuid
|
||||
@@ -28,10 +28,14 @@ class ApplicationMailer < ActionMailer::Base
|
||||
end
|
||||
|
||||
def assign_message_metadata(tag, record)
|
||||
@message_metadata = {
|
||||
@message_metadata = (@message_metadata || {}).merge(
|
||||
'tag' => tag,
|
||||
'record_id' => record.id,
|
||||
'record_type' => record.class.name
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def put_metadata(attrs)
|
||||
@message_metadata = (@message_metadata || {}).merge(attrs)
|
||||
end
|
||||
end
|
||||
@@ -233,9 +233,17 @@ class SubmitterMailer < ApplicationMailer
|
||||
def from_address_for_submitter(submitter)
|
||||
if submitter.submission.source.in?(%w[api embed]) &&
|
||||
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
|
||||
user = submitter.account.users.find_by(email: from_email)
|
||||
|
||||
put_metadata('from_user_id' => user.id)
|
||||
|
||||
from_email
|
||||
else
|
||||
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
|
||||
user = submitter.submission.created_by_user || submitter.submission.template.author
|
||||
|
||||
put_metadata('from_user_id' => user.id)
|
||||
|
||||
user.friendly_name
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,10 +18,10 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_email_events_on_account_id (account_id)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
|
||||
# index_email_events_on_email (email)
|
||||
# index_email_events_on_emailable (emailable_type,emailable_id)
|
||||
# index_email_events_on_message_id (message_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
|
||||
@@ -65,13 +65,19 @@ class Submission < ApplicationRecord
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).group(:id) }
|
||||
scope :pending, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :completed, lambda {
|
||||
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :declined, -> { joins(:submitters).where.not(submitters: { declined_at: nil }).group(:id) }
|
||||
scope :expired, -> { where(expire_at: ..Time.current) }
|
||||
scope :declined, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
|
||||
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
|
||||
}
|
||||
scope :expired, -> { pending.where(expire_at: ..Time.current) }
|
||||
|
||||
enum :source, {
|
||||
invite: 'invite',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# name :string not null
|
||||
# preferences :text not null
|
||||
# schema :text not null
|
||||
# shared_link :boolean default(FALSE), not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# submitters :text not null
|
||||
|
||||
@@ -73,6 +73,8 @@ class User < ApplicationRecord
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :admins, -> { where(role: ADMIN_ROLE) }
|
||||
|
||||
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
|
||||
|
||||
def access_token
|
||||
super || build_access_token.tap(&:save!)
|
||||
end
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<% if @pagy.pages > 1 %>
|
||||
<div class="flex my-6 justify-center md:justify-between">
|
||||
<div class="hidden md:block text-sm">
|
||||
<%= @pagy.from %>-<%= local_assigns.fetch(:to, @pagy.to) %> of <%= local_assigns.fetch(:count, @pagy.count) %> <%= local_assigns[:items_name] || 'items' %>
|
||||
<% if @pagy.count.nil? %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_without_total", from: local_assigns.fetch(:from, @pagy.from), to: local_assigns.fetch(:to, @pagy.to)) %>
|
||||
<% else %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_with_total", from: local_assigns.fetch(:from, @pagy.from), to: local_assigns.fetch(:to, @pagy.to), count: local_assigns.fetch(:count, @pagy.count)) %>
|
||||
<% end %>
|
||||
<%= local_assigns[:left_additional_html] %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<% if configs = account.account_configs.find_by(key: AccountConfig::POLICY_LINKS_KEY) %>
|
||||
<div class="max-w-md mx-auto flex flex-wrap gap-1 justify-center text-sm text-base-content/60 mt-2">
|
||||
<%= auto_link(MarkdownToHtml.call(configs.value)) %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -23,12 +23,12 @@
|
||||
</div>
|
||||
<% if Docuseal.multitenant? || Accounts.can_send_emails?(@submitter.account) %>
|
||||
<toggle-submit class="block">
|
||||
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, class: 'base-button w-full' %>
|
||||
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
<% if Templates.filter_undefined_submitters(@template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
|
||||
<% if Templates.filter_undefined_submitters(@template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
|
||||
<toggle-submit class="block">
|
||||
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>
|
||||
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, class: 'white-button w-full' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
|
||||
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
|
||||
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
|
||||
<div class="space-y-6 mx-auto">
|
||||
<div class="space-y-6">
|
||||
<div class="text-center w-full space-y-6">
|
||||
<%= render 'banner' %>
|
||||
<p class="text-xl font-semibold text-center">
|
||||
<%= t('share_link_is_currently_disabled') %>
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<% if @template.archived_at? %>
|
||||
<p dir="auto" class="text-sm"><%= t('form_has_been_deleted_by_html', name: @template.account.name) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
|
||||
@@ -42,3 +42,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
|
||||
<%= render 'start_form/policy', account: @template.account %>
|
||||
@@ -6,11 +6,11 @@
|
||||
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>">
|
||||
<% if field['type'] == 'signature' %>
|
||||
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
|
||||
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow ? 'flex-row' : 'flex-col' %>">
|
||||
<div class="flex overflow-hidden <%= is_narrow ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
|
||||
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
|
||||
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
|
||||
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>">
|
||||
</div>
|
||||
<% if local_assigns[:with_signature_id] && attachment = attachments_index[value] %>
|
||||
<% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %>
|
||||
<div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
|
||||
<div class="truncate uppercase">
|
||||
ID: <%= attachment.uuid %>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= render 'submissions_filters/filter_button', filter_params: %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||
<div class="space-y-4">
|
||||
<%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true, archived: true } %>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
||||
<% is_show_tabs = @pagy.count >= 5 || params[:status].present? || filter_params.present? %>
|
||||
<% is_show_tabs = (@pagy.count.nil? || @pagy.count >= 5) || params[:status].present? || filter_params.present? %>
|
||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||
<div class="flex justify-between items-center w-full mb-4">
|
||||
<div class="flex items-center flex-grow min-w-0">
|
||||
@@ -61,12 +61,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||
<div class="space-y-4">
|
||||
<%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if params[:q].blank? && params[:status].blank? && filter_params.blank? && @pagy.count < 5 %>
|
||||
<% if params[:q].blank? && params[:status].blank? && filter_params.blank? && @pagy.count.present? && @pagy.count < 5 %>
|
||||
<%= render 'templates/dropzone' %>
|
||||
<% end %>
|
||||
<% if @submissions.present? || (params[:q].blank? && filter_params.blank?) %>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
<%= f.hidden_field :submission_slug, value: @submission.slug %>
|
||||
<%= f.label :email, t('email'), class: 'label' %>
|
||||
<%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %>
|
||||
<% if params[:status] == 'error' %>
|
||||
<span class="label-text-alt text-red-400 mt-1">
|
||||
<%= t('please_enter_your_email_address_associated_with_the_completed_submission') %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<toggle-submit dir="auto" class="form-control">
|
||||
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template) %>
|
||||
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %>
|
||||
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
<toggle-submit class="block">
|
||||
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>
|
||||
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), resubmit_form_path, params: { resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -39,13 +39,13 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<%= render partial: 'templates/template', collection: @templates %>
|
||||
</div>
|
||||
<% templates_order_select_html = capture do %>
|
||||
<% if params[:q].blank? && @pagy.pages > 1 %>
|
||||
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %>
|
||||
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<% submitter = submitters.first %>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<% if submission.expired? %>
|
||||
<% if submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
|
||||
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
|
||||
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
|
||||
<%= t('expired') %>
|
||||
|
||||
@@ -49,7 +49,12 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), id: 'share_link_clipboard', class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', icon_class: 'w-4 h-4 md:w-6 md:h-6 text-white', copy_title: t('link'), copied_title: t('copied'), copy_title_md: t('link'), copied_title_md: t('copied') %>
|
||||
<%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %>
|
||||
<span><%= t('link') %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= render 'title', template: @template %>
|
||||
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
||||
<% is_show_tabs = @pagy.pages > 1 || params[:q].present? || params[:status].present? || filter_params.present? %>
|
||||
<% if !@pagy.count.zero? || params[:q].present? || params[:status].present? || filter_params.present? %>
|
||||
<% if @pagy.count.nil? || !@pagy.count.zero? || params[:q].present? || params[:status].present? || filter_params.present? %>
|
||||
<div class="<%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
|
||||
<div class="flex justify-between items-center md:items-end">
|
||||
<div>
|
||||
@@ -35,27 +35,33 @@
|
||||
<%= svg_icon('list', class: 'w-5 h-5') %>
|
||||
<span class="font-normal"><%= t('all') %></span>
|
||||
</div>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% unless can?(:manage, :countless) %>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% end %>
|
||||
</a>
|
||||
<a href="<%= url_for(params.to_unsafe_h.merge(status: :pending)) %>" class="<%= params[:status] == 'pending' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700">
|
||||
<div class="flex items-center space-x-1">
|
||||
<%= svg_icon('clock', class: 'w-5 h-5') %>
|
||||
<span class="font-normal"><%= t('pending') %></span>
|
||||
</div>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% unless can?(:manage, :countless) %>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% end %>
|
||||
</a>
|
||||
<a href="<%= url_for(params.to_unsafe_h.merge(status: :completed)) %>" class="<%= params[:status] == 'completed' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700">
|
||||
<div class="flex items-center space-x-1">
|
||||
<%= svg_icon('circle_check', class: 'w-5 h-5') %>
|
||||
<span class="font-normal"><%= t('completed') %></span>
|
||||
</div>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% unless can?(:manage, :countless) %>
|
||||
<div class="badge badge-neutral badge-outline font-medium">
|
||||
<%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.unscope(:group, :order).select(:id).distinct.count %>
|
||||
</div>
|
||||
<% end %>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-end flex-col md:flex-row gap-2 w-full md:w-fit">
|
||||
@@ -89,7 +95,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @template.submitters.size == 1 %>
|
||||
<%= link_to start_form_url(slug: @template.slug), id: 'sign_yourself_button', class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
|
||||
<%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
|
||||
<%= svg_icon('writing', class: 'w-6 h-6') %>
|
||||
<span class="mr-1"><%= t('sign_it_yourself') %></span>
|
||||
<% end %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<%= render 'shared/search_input', placeholder: "#{t('search')}..." %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<%= render partial: 'templates/template', collection: @templates %>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @pagy.count > 0 %>
|
||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||
<div class="space-y-4">
|
||||
<%= render partial: 'templates/submission', collection: @submissions, locals: { template: @template, archived: true } %>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
|
||||
<% end %>
|
||||
<div class="flex items-center flex-grow min-w-0">
|
||||
<% if has_archived || @pagy.count > 0 || @template_folders.present? %>
|
||||
<% if has_archived || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
|
||||
<div class="mr-2">
|
||||
<%= render 'dashboard/toggle_view', selected: 'templates' %>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
<% end %>
|
||||
<% templates_order_select_html = capture do %>
|
||||
<% if params[:q].blank? && @pagy.pages > 1 %>
|
||||
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %>
|
||||
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @template_folders.present? %>
|
||||
@@ -84,9 +84,9 @@
|
||||
<% if show_dropzone %>
|
||||
<%= render 'templates/dropzone' %>
|
||||
<% end %>
|
||||
<% if @templates.present? || params[:q].blank? %>
|
||||
<% if @templates.present? || @template_folders.present? || params[:q].blank? %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html, right_additional_html: templates_order_select_html %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', left_additional_html: view_archived_html, right_additional_html: templates_order_select_html %>
|
||||
<% else %>
|
||||
<div class="mt-2">
|
||||
<%= view_archived_html %>
|
||||
|
||||
@@ -380,8 +380,18 @@
|
||||
<%= t('embedding_url') %>
|
||||
</label>
|
||||
<div class="flex gap-2 mb-4 mt-2">
|
||||
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'w-full mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<div class="flex gap-2">
|
||||
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
|
||||
<check-on-click data-element-id="template_shared_link">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
</check-on-click>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-1 pt-3">
|
||||
<span><%= t('enable_shared_link') %></span>
|
||||
<%= f.check_box :shared_link, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'templates_code_modal/placeholder' %>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
|
||||
<div class="mt-2 mb-4 px-5">
|
||||
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
|
||||
<div class="flex items-center justify-between gap-1 px-1">
|
||||
<span><%= t('enable_shared_link') %></span>
|
||||
<%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
|
||||
<check-on-click data-element-id="template_shared_link">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
</check-on-click>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'pagy/extras/countless'
|
||||
|
||||
Pagy::DEFAULT[:limit] = 10
|
||||
Pagy::DEFAULT.freeze
|
||||
|
||||
|
||||
+175
-7
@@ -22,6 +22,9 @@ en: &en
|
||||
hi_there: Hi there
|
||||
thanks: Thanks
|
||||
private: Private
|
||||
stripe_integration: Stripe Integration
|
||||
stripe_account_has_been_connected: Stripe account has been connected.
|
||||
re_connect_stripe: Re-connect Stripe
|
||||
bcc_recipients: BCC recipients
|
||||
resend_pending: Re-send pending
|
||||
always_enforce_signing_order: Always enforce the signing order
|
||||
@@ -46,7 +49,7 @@ en: &en
|
||||
team_member_permissions: Team member permissions
|
||||
entire_team: Entire team
|
||||
admin_only: Admin only
|
||||
accessiable_by: Accessiable by
|
||||
accessiable_by: Accessible by
|
||||
team_access: Team access
|
||||
document_download_filename_format: Document download filename format
|
||||
document_name: Document Name
|
||||
@@ -655,7 +658,7 @@ en: &en
|
||||
completed_at: Completed at
|
||||
edit_recipient: Edit Recipient
|
||||
update_recipient: Update Recipient
|
||||
use_international_format_1xxx_: 'Use internatioanl format: +1xxx...'
|
||||
use_international_format_1xxx_: 'Use international format: +1xxx...'
|
||||
submitter_cannot_be_updated: Submitter cannot be updated.
|
||||
at_least_one_field_must_be_filled: At least one field must be filled.
|
||||
archived_users: Archived Users
|
||||
@@ -741,11 +744,16 @@ en: &en
|
||||
two_months: 2 months
|
||||
three_months: 3 months
|
||||
eu_data_residency: EU data residency
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Please enter your email address associated with the completed submission.
|
||||
esignature_disclosure: eSignature Disclosure
|
||||
share_link: Share link
|
||||
enable_shared_link: Enable shared link
|
||||
share_link_is_currently_disabled: Share link is currently disabled
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Bulk Send
|
||||
embed: Embedding
|
||||
invie: Invite
|
||||
invite: Invite
|
||||
link: Link
|
||||
submission_event_names:
|
||||
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
|
||||
@@ -825,8 +833,27 @@ en: &en
|
||||
scopes:
|
||||
write: Update your data
|
||||
read: Read your data
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from}-%{to} of %{count} submissions"
|
||||
range_without_total: "%{from}-%{to} submissions"
|
||||
templates:
|
||||
range_with_total: "%{from}-%{to} of %{count} templates"
|
||||
range_without_total: "%{from}-%{to} templates"
|
||||
template_folders:
|
||||
range_with_total: "%{from}-%{to} of %{count} folders"
|
||||
range_without_total: "%{from}-%{to} folders"
|
||||
users:
|
||||
range_with_total: "%{from}-%{to} of %{count} users"
|
||||
range_without_total: "%{from}-%{to} users"
|
||||
items:
|
||||
range_with_total: "%{from}-%{to} of %{count} items"
|
||||
range_without_total: "%{from}-%{to} items"
|
||||
|
||||
es: &es
|
||||
stripe_integration: Integración con Stripe
|
||||
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
|
||||
re_connect_stripe: Volver a conectar Stripe
|
||||
private: Privado
|
||||
resend_pending: Reenviar pendiente
|
||||
ensure_unique_recipients: Asegurar destinatarios únicos
|
||||
@@ -1549,6 +1576,11 @@ es: &es
|
||||
two_months: 2 meses
|
||||
three_months: 3 meses
|
||||
eu_data_residency: Datos alojados UE
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, introduce tu dirección de correo electrónico asociada con el envío completado.
|
||||
esignature_disclosure: Uso de firma electrónica
|
||||
share_link: Enlace para compartir
|
||||
enable_shared_link: Habilitar enlace compartido
|
||||
share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Envío masivo
|
||||
@@ -1633,8 +1665,27 @@ es: &es
|
||||
scopes:
|
||||
write: Actualizar tus datos
|
||||
read: Leer tus datos
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from}-%{to} de %{count} envíos"
|
||||
range_without_total: "%{from}-%{to} envíos"
|
||||
templates:
|
||||
range_with_total: "%{from}-%{to} de %{count} plantillas"
|
||||
range_without_total: "%{from}-%{to} plantillas"
|
||||
template_folders:
|
||||
range_with_total: "%{from}-%{to} de %{count} carpetas"
|
||||
range_without_total: "%{from}-%{to} carpetas"
|
||||
users:
|
||||
range_with_total: "%{from}-%{to} de %{count} usuarios"
|
||||
range_without_total: "%{from}-%{to} usuarios"
|
||||
items:
|
||||
range_with_total: "%{from}-%{to} de %{count} elementos"
|
||||
range_without_total: "%{from}-%{to} elementos"
|
||||
|
||||
it: &it
|
||||
stripe_integration: Integrazione Stripe
|
||||
stripe_account_has_been_connected: L'account Stripe è stato collegato.
|
||||
re_connect_stripe: Ricollega Stripe
|
||||
private: Privato
|
||||
resend_pending: Reinvia in sospeso
|
||||
ensure_unique_recipients: Assicurarsi destinatari unici
|
||||
@@ -2355,6 +2406,11 @@ it: &it
|
||||
two_months: 2 mesi
|
||||
three_months: 3 mesi
|
||||
eu_data_residency: "Dati nell'UE"
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: "Inserisci il tuo indirizzo email associato all'invio completato."
|
||||
esignature_disclosure: Uso della firma elettronica
|
||||
share_link: Link di condivisione
|
||||
enable_shared_link: Abilita link condiviso
|
||||
share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Invio massivo
|
||||
@@ -2439,8 +2495,27 @@ it: &it
|
||||
scopes:
|
||||
write: Aggiorna i tuoi dati
|
||||
read: Leggi i tuoi dati
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from}-%{to} di %{count} invii"
|
||||
range_without_total: "%{from}-%{to} invii"
|
||||
templates:
|
||||
range_with_total: "%{from}-%{to} di %{count} modelli"
|
||||
range_without_total: "%{from}-%{to} modelli"
|
||||
template_folders:
|
||||
range_with_total: "%{from}-%{to} di %{count} cartelle"
|
||||
range_without_total: "%{from}-%{to} cartelle"
|
||||
users:
|
||||
range_with_total: "%{from}-%{to} di %{count} utenti"
|
||||
range_without_total: "%{from}-%{to} utenti"
|
||||
items:
|
||||
range_with_total: "%{from}-%{to} di %{count} elementi"
|
||||
range_without_total: "%{from}-%{to} elementi"
|
||||
|
||||
fr: &fr
|
||||
stripe_integration: Intégration Stripe
|
||||
stripe_account_has_been_connected: Le compte Stripe a été connecté.
|
||||
re_connect_stripe: Reconnecter Stripe
|
||||
private: Privé
|
||||
resend_pending: Renvoyer en attente
|
||||
ensure_unique_recipients: Assurer l'unicité des destinataires
|
||||
@@ -3164,6 +3239,11 @@ fr: &fr
|
||||
two_months: 2 mois
|
||||
three_months: 3 mois
|
||||
eu_data_residency: "Données dans l'UE"
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: "Veuillez saisir l'adresse e-mail associée à l'envoi complété."
|
||||
esignature_disclosure: Divulgation de Signature Électronique
|
||||
share_link: Lien de partage
|
||||
enable_shared_link: Activer le lien de partage
|
||||
share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Envoi en masse
|
||||
@@ -3248,8 +3328,27 @@ fr: &fr
|
||||
scopes:
|
||||
write: Mettre à jour vos données
|
||||
read: Lire vos données
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from} à %{to} sur %{count} soumissions"
|
||||
range_without_total: "%{from} à %{to} soumissions"
|
||||
templates:
|
||||
range_with_total: "%{from} à %{to} sur %{count} modèles"
|
||||
range_without_total: "%{from} à %{to} modèles"
|
||||
template_folders:
|
||||
range_with_total: "%{from} à %{to} sur %{count} dossiers"
|
||||
range_without_total: "%{from} à %{to} dossiers"
|
||||
users:
|
||||
range_with_total: "%{from} à %{to} sur %{count} utilisateurs"
|
||||
range_without_total: "%{from} à %{to} utilisateurs"
|
||||
items:
|
||||
range_with_total: "%{from} à %{to} sur %{count} éléments"
|
||||
range_without_total: "%{from} à %{to} éléments"
|
||||
|
||||
pt: &pt
|
||||
stripe_integration: Integração com Stripe
|
||||
stripe_account_has_been_connected: Conta Stripe foi conectada.
|
||||
re_connect_stripe: Reconectar Stripe
|
||||
private: Privado
|
||||
resend_pending: Re-enviar pendente
|
||||
ensure_unique_recipients: Garantir destinatários únicos
|
||||
@@ -3972,6 +4071,11 @@ pt: &pt
|
||||
two_months: 2 meses
|
||||
three_months: 3 meses
|
||||
eu_data_residency: Dados na UE
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, insira seu e-mail associado ao envio concluído.
|
||||
esignature_disclosure: Uso de assinatura eletrônica
|
||||
share_link: Link de compartilhamento
|
||||
enable_shared_link: Ativar link compartilhado
|
||||
share_link_is_currently_disabled: O link compartilhado está desativado no momento
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Envio em massa
|
||||
@@ -4016,8 +4120,8 @@ pt: &pt
|
||||
start: Iniciar
|
||||
previous: Anterior
|
||||
next: Próximo
|
||||
template_and_submissions: 'Modelos e Envios'
|
||||
template_and_submissions_description: "Você pode selecionar a visualização que melhor se adapta ao seu fluxo de trabalho. Escolha a visualização 'Modelos' para criar modelos reutilizáveis de documentos ou 'Envios' para assinar documentos individuais ou verificar o status de cada solicitação de assinatura."
|
||||
template_and_submissions: 'Modelos e Submissões'
|
||||
template_and_submissions_description: "Você pode selecionar a visualização que melhor se adapta ao seu fluxo de trabalho. Escolha a visualização 'Modelos' para criar modelos reutilizáveis de documentos ou 'Submissões' para assinar documentos individuais ou verificar o status de cada solicitação de assinatura."
|
||||
upload_a_pdf_file: 'Enviar um arquivo PDF'
|
||||
upload_a_pdf_file_description: 'Envie um documento PDF para criar um modelo de formulário de assinatura.'
|
||||
select_a_signer_party: 'Selecionar parte assinante'
|
||||
@@ -4057,8 +4161,27 @@ pt: &pt
|
||||
scopes:
|
||||
write: Atualizar seus dados
|
||||
read: Ler seus dados
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from}-%{to} de %{count} submissões"
|
||||
range_without_total: "%{from}-%{to} submissões"
|
||||
templates:
|
||||
range_with_total: "%{from}-%{to} de %{count} modelos"
|
||||
range_without_total: "%{from}-%{to} modelos"
|
||||
template_folders:
|
||||
range_with_total: "%{from}-%{to} de %{count} pastas"
|
||||
range_without_total: "%{from}-%{to} pastas"
|
||||
users:
|
||||
range_with_total: "%{from}-%{to} de %{count} usuários"
|
||||
range_without_total: "%{from}-%{to} usuários"
|
||||
items:
|
||||
range_with_total: "%{from}-%{to} de %{count} itens"
|
||||
range_without_total: "%{from}-%{to} itens"
|
||||
|
||||
de: &de
|
||||
stripe_integration: Stripe-Integration
|
||||
stripe_account_has_been_connected: Stripe-Konto wurde verbunden.
|
||||
re_connect_stripe: Stripe erneut verbinden
|
||||
private: Privat
|
||||
resend_pending: Ausstehende erneut senden
|
||||
ensure_unique_recipients: Stellen Sie einzigartige Empfänger sicher
|
||||
@@ -4164,9 +4287,9 @@ de: &de
|
||||
sending: Senden
|
||||
resubmit: Erneut einreichen
|
||||
or: oder
|
||||
download_documents: Dokumente herunterladen
|
||||
download_documents: Dokumente downloaden
|
||||
downloading: Lädt herunter
|
||||
download: Laden
|
||||
download: Download
|
||||
decline: Ablehnen
|
||||
declined: Abgelehnt
|
||||
decline_reason: Ablehnungsgrund
|
||||
@@ -4781,6 +4904,11 @@ de: &de
|
||||
two_months: 2 Monate
|
||||
three_months: 3 Monate
|
||||
eu_data_residency: EU-Datenspeicher
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Bitte gib deine E-Mail-Adresse ein, die mit der abgeschlossenen Übermittlung verknüpft ist.
|
||||
esignature_disclosure: Nutzung der E-Signatur
|
||||
share_link: Freigabelink
|
||||
enable_shared_link: 'Freigabelink aktivieren'
|
||||
share_link_is_currently_disabled: 'Freigabelink ist derzeit deaktiviert'
|
||||
submission_sources:
|
||||
api: API
|
||||
bulk: Massenversand
|
||||
@@ -4865,6 +4993,22 @@ de: &de
|
||||
scopes:
|
||||
write: Aktualisiere deine Daten
|
||||
read: Lese deine Daten
|
||||
pagination:
|
||||
submissions:
|
||||
range_with_total: "%{from}-%{to} von %{count} Einreichungen"
|
||||
range_without_total: "%{from}-%{to} Einreichungen"
|
||||
templates:
|
||||
range_with_total: "%{from}-%{to} von %{count} Vorlagen"
|
||||
range_without_total: "%{from}-%{to} Vorlagen"
|
||||
template_folders:
|
||||
range_with_total: "%{from}-%{to} von %{count} Ordnern"
|
||||
range_without_total: "%{from}-%{to} Ordner"
|
||||
users:
|
||||
range_with_total: "%{from}-%{to} von %{count} Benutzern"
|
||||
range_without_total: "%{from}-%{to} Benutzer"
|
||||
items:
|
||||
range_with_total: "%{from}-%{to} von %{count} Elementen"
|
||||
range_without_total: "%{from}-%{to} Elemente"
|
||||
|
||||
pl:
|
||||
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia
|
||||
@@ -4933,6 +5077,9 @@ pl:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> dokumentów podpisanych za pomocą'
|
||||
open_source_documents_software: 'oprogramowanie do dokumentów open source'
|
||||
eu_data_residency: Dane w UE
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Wprowadź adres e-mail powiązany z ukończonym zgłoszeniem.
|
||||
privacy_policy: Polityka Prywatności
|
||||
esignature_disclosure: Użycie e-podpisu
|
||||
|
||||
uk:
|
||||
require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття
|
||||
@@ -5001,6 +5148,9 @@ uk:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> документів підписано за допомогою'
|
||||
open_source_documents_software: 'відкрите програмне забезпечення для документів'
|
||||
eu_data_residency: 'Зберігання даних в ЄС'
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: "Введіть адресу електронної пошти, пов'язану із завершеним поданням."
|
||||
privacy_policy: Політика конфіденційності
|
||||
esignature_disclosure: Використання e-підпису
|
||||
|
||||
cs:
|
||||
require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA
|
||||
@@ -5069,6 +5219,9 @@ cs:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> dokumentů podepsáno pomocí'
|
||||
open_source_documents_software: 'open source software pro dokumenty'
|
||||
eu_data_residency: 'Uložení dat v EU'
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Zadejte e-mailovou adresu spojenou s dokončeným odesláním.
|
||||
privacy_policy: Zásady Ochrany Osobních Údajů
|
||||
esignature_disclosure: Použití e-podpisu
|
||||
|
||||
he:
|
||||
require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה
|
||||
@@ -5137,6 +5290,9 @@ he:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> מסמכים נחתמו באמצעות'
|
||||
open_source_documents_software: 'תוכנה בקוד פתוח למסמכים'
|
||||
eu_data_residency: 'נתונים באיחוד האירופי '
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: 'אנא הזן את כתובת הדוא"ל המשויכת למשלוח שהושלם.'
|
||||
privacy_policy: 'מדיניות פרטיות'
|
||||
esignature_disclosure: 'גילוי חתימה אלקטרונית'
|
||||
|
||||
nl:
|
||||
require_phone_2fa_to_open: Vereis telefoon 2FA om te openen
|
||||
@@ -5205,6 +5361,9 @@ nl:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> documenten ondertekend met'
|
||||
open_source_documents_software: 'open source documenten software'
|
||||
eu_data_residency: Gegevens EU
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: Voer het e-mailadres in dat is gekoppeld aan de voltooide indiening.
|
||||
privacy_policy: Privacybeleid
|
||||
esignature_disclosure: Gebruik van e-handtekening
|
||||
|
||||
ar:
|
||||
require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين"
|
||||
@@ -5273,6 +5432,9 @@ ar:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> مستندات تم توقيعها باستخدام'
|
||||
open_source_documents_software: 'برنامج مستندات مفتوح المصدر'
|
||||
eu_data_residency: 'بيانات في الاتحاد الأوروبي'
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: 'يرجى إدخال عنوان البريد الإلكتروني المرتبط بالإرسال المكتمل.'
|
||||
privacy_policy: 'سياسة الخصوصية'
|
||||
esignature_disclosure: 'إفصاح التوقيع الإلكتروني'
|
||||
|
||||
ko:
|
||||
require_phone_2fa_to_open: 휴대폰 2FA를 열 때 요구함
|
||||
@@ -5341,6 +5503,9 @@ ko:
|
||||
count_documents_signed_with_html: '<b>%{count}</b>개의 문서가 다음을 통해 서명됨'
|
||||
open_source_documents_software: '오픈소스 문서 소프트웨어'
|
||||
eu_data_residency: 'EU 데이터 보관'
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: '완료된 제출과 연결된 이메일 주소를 입력하세요.'
|
||||
privacy_policy: 개인정보 처리방침
|
||||
esignature_disclosure: 전자서명 공개
|
||||
|
||||
ja:
|
||||
require_phone_2fa_to_open: 電話による2段階認証が必要です
|
||||
@@ -5409,6 +5574,9 @@ ja:
|
||||
count_documents_signed_with_html: '<b>%{count}</b> 件のドキュメントが以下で署名されました'
|
||||
open_source_documents_software: 'オープンソースのドキュメントソフトウェア'
|
||||
eu_data_residency: 'EU データ居住'
|
||||
please_enter_your_email_address_associated_with_the_completed_submission: '完了した提出に関連付けられたメールアドレスを入力してください。'
|
||||
privacy_policy: プライバシーポリシー
|
||||
esignature_disclosure: 電子署名に関する開示
|
||||
|
||||
en-US:
|
||||
<<: *en
|
||||
|
||||
+4
-1
@@ -57,7 +57,7 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :verify_pdf_signature, only: %i[create]
|
||||
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
|
||||
resources :account_configs, only: %i[create]
|
||||
resources :account_configs, only: %i[create destroy]
|
||||
resources :user_configs, only: %i[create]
|
||||
resources :encrypted_user_configs, only: %i[destroy]
|
||||
resources :timestamp_server, only: %i[create]
|
||||
@@ -106,6 +106,7 @@ Rails.application.routes.draw do
|
||||
resource :form, only: %i[show], controller: 'templates_form_preview'
|
||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||
resource :preferences, only: %i[show create], controller: 'templates_preferences'
|
||||
resource :share_link, only: %i[show create], controller: 'templates_share_link'
|
||||
resources :recipients, only: %i[create], controller: 'templates_recipients'
|
||||
resources :submissions_export, only: %i[index new]
|
||||
end
|
||||
@@ -131,6 +132,8 @@ Rails.application.routes.draw do
|
||||
get :completed
|
||||
end
|
||||
|
||||
resource :resubmit_form, controller: 'start_form', only: :update
|
||||
|
||||
resources :submit_form, only: %i[], path: '' do
|
||||
get :success, on: :collection
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddSharedLinkToTemplates < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction
|
||||
|
||||
class MigrationTemplate < ActiveRecord::Base
|
||||
self.table_name = 'templates'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :templates, :shared_link, :boolean, if_not_exists: true
|
||||
|
||||
MigrationTemplate.where(shared_link: nil).in_batches.update_all(shared_link: true)
|
||||
|
||||
change_column_default :templates, :shared_link, from: nil, to: false
|
||||
change_column_null :templates, :shared_link, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :templates, :shared_link
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddEmailEventsDateIndex < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_index :email_events, :account_id
|
||||
add_index :email_events, %i[account_id event_datetime]
|
||||
end
|
||||
end
|
||||
+3
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
@@ -174,7 +174,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do
|
||||
t.text "data", null: false
|
||||
t.datetime "event_datetime", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["account_id"], name: "index_email_events_on_account_id"
|
||||
t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
|
||||
t.index ["email"], name: "index_email_events_on_email"
|
||||
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
|
||||
t.index ["message_id"], name: "index_email_events_on_message_id"
|
||||
@@ -361,6 +361,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do
|
||||
t.bigint "folder_id", null: false
|
||||
t.string "external_id"
|
||||
t.text "preferences", null: false
|
||||
t.boolean "shared_link", default: false, null: false
|
||||
t.index ["account_id"], name: "index_templates_on_account_id"
|
||||
t.index ["author_id"], name: "index_templates_on_author_id"
|
||||
t.index ["external_id"], name: "index_templates_on_external_id"
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LoadBmp
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(bmp_bytes)
|
||||
bmp_bytes = bmp_bytes.b
|
||||
|
||||
header_data = parse_bmp_headers(bmp_bytes)
|
||||
|
||||
raw_pixel_data_from_file = extract_raw_pixel_data_blob(
|
||||
bmp_bytes,
|
||||
header_data[:pixel_data_offset],
|
||||
header_data[:bmp_stride],
|
||||
header_data[:height]
|
||||
)
|
||||
|
||||
final_pixel_data = prepare_unpadded_pixel_data_string(
|
||||
raw_pixel_data_from_file,
|
||||
header_data[:bpp],
|
||||
header_data[:width],
|
||||
header_data[:height],
|
||||
header_data[:bmp_stride]
|
||||
)
|
||||
|
||||
bands = header_data[:bpp] / 8
|
||||
|
||||
unless header_data[:bpp] == 24 || header_data[:bpp] == 32
|
||||
raise ArgumentError, "Conversion for #{header_data[:bpp]}-bpp BMP not implemented."
|
||||
end
|
||||
|
||||
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
|
||||
|
||||
image = image.flip(:vertical) if header_data[:orientation] == -1
|
||||
|
||||
image_rgb =
|
||||
if bands == 3
|
||||
image.recomb(band3_recomb)
|
||||
elsif bands == 4
|
||||
image.recomb(band4_recomb)
|
||||
end
|
||||
|
||||
image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
|
||||
|
||||
image_rgb
|
||||
end
|
||||
|
||||
def parse_bmp_headers(bmp_bytes)
|
||||
raise ArgumentError, 'BMP data too short for file header (14 bytes).' if bmp_bytes.bytesize < 14
|
||||
|
||||
signature, pixel_data_offset = bmp_bytes.unpack('a2@10L<')
|
||||
|
||||
raise ArgumentError, "Not a valid BMP file (invalid signature 'BM')." if signature != 'BM'
|
||||
|
||||
raise ArgumentError, 'BMP data too short for info header size field (4 bytes).' if bmp_bytes.bytesize < (14 + 4)
|
||||
|
||||
info_header_size = bmp_bytes.unpack1('@14L<')
|
||||
|
||||
min_expected_info_header_size = 40
|
||||
|
||||
if info_header_size < min_expected_info_header_size
|
||||
raise ArgumentError,
|
||||
"Unsupported BMP info header size: #{info_header_size}. Expected at least #{min_expected_info_header_size}."
|
||||
end
|
||||
|
||||
header_and_info_header_min_bytes = 14 + min_expected_info_header_size
|
||||
|
||||
if bmp_bytes.bytesize < header_and_info_header_min_bytes
|
||||
raise ArgumentError,
|
||||
'BMP data too short for essential BITMAPINFOHEADER fields ' \
|
||||
"(requires #{header_and_info_header_min_bytes} bytes total)."
|
||||
end
|
||||
|
||||
_header_size_check, width, raw_height_from_header, planes, bpp, compression =
|
||||
bmp_bytes.unpack('@14L<l<l<S<S<L<')
|
||||
|
||||
height = 0
|
||||
orientation = -1
|
||||
|
||||
if raw_height_from_header.negative?
|
||||
height = -raw_height_from_header
|
||||
orientation = 1
|
||||
else
|
||||
height = raw_height_from_header
|
||||
end
|
||||
|
||||
raise ArgumentError, 'BMP width must be positive.' if width <= 0
|
||||
raise ArgumentError, 'BMP height must be positive.' if height <= 0
|
||||
|
||||
if compression != 0
|
||||
raise ArgumentError,
|
||||
"Unsupported BMP compression type: #{compression}. Only uncompressed (0) is supported."
|
||||
end
|
||||
|
||||
unless [24, 32].include?(bpp)
|
||||
raise ArgumentError, "Unsupported BMP bits per pixel: #{bpp}. Only 24-bit and 32-bit are supported."
|
||||
end
|
||||
|
||||
raise ArgumentError, "Unsupported BMP planes: #{planes}. Expected 1." if planes != 1
|
||||
|
||||
bytes_per_pixel = bpp / 8
|
||||
row_size_unpadded = width * bytes_per_pixel
|
||||
bmp_stride = (row_size_unpadded + 3) & ~3
|
||||
|
||||
{
|
||||
width:,
|
||||
height:,
|
||||
bpp:,
|
||||
pixel_data_offset:,
|
||||
bmp_stride:,
|
||||
orientation:
|
||||
}
|
||||
end
|
||||
|
||||
def extract_raw_pixel_data_blob(bmp_bytes, pixel_data_offset, bmp_stride, height)
|
||||
expected_pixel_data_size = bmp_stride * height
|
||||
|
||||
if pixel_data_offset + expected_pixel_data_size > bmp_bytes.bytesize
|
||||
actual_available = bmp_bytes.bytesize - pixel_data_offset
|
||||
actual_available = 0 if actual_available.negative?
|
||||
raise ArgumentError,
|
||||
"Pixel data segment (offset #{pixel_data_offset}, expected size #{expected_pixel_data_size}) " \
|
||||
"exceeds BMP file size (#{bmp_bytes.bytesize}). " \
|
||||
"Only #{actual_available} bytes available after offset."
|
||||
end
|
||||
|
||||
raw_pixel_data_from_file = bmp_bytes.byteslice(pixel_data_offset, expected_pixel_data_size)
|
||||
|
||||
if raw_pixel_data_from_file.nil? || raw_pixel_data_from_file.bytesize < expected_pixel_data_size
|
||||
raise ArgumentError,
|
||||
"Extracted pixel data is smaller (#{raw_pixel_data_from_file&.bytesize || 0} bytes) " \
|
||||
"than expected (#{expected_pixel_data_size} bytes based on stride and height)."
|
||||
end
|
||||
|
||||
raw_pixel_data_from_file
|
||||
end
|
||||
|
||||
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
|
||||
bytes_per_pixel = bpp / 8
|
||||
actual_row_width_bytes = width * bytes_per_pixel
|
||||
|
||||
unpadded_rows = Array.new(height)
|
||||
current_offset_in_blob = 0
|
||||
|
||||
height.times do |i|
|
||||
if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
|
||||
raise ArgumentError,
|
||||
"Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
|
||||
"row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
|
||||
end
|
||||
|
||||
unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
|
||||
|
||||
if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
|
||||
raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
|
||||
end
|
||||
|
||||
unpadded_rows[i] = unpadded_row_slice
|
||||
current_offset_in_blob += bmp_stride
|
||||
end
|
||||
|
||||
unpadded_rows.join
|
||||
end
|
||||
|
||||
def band3_recomb
|
||||
@band3_recomb ||=
|
||||
Vips::Image.new_from_array(
|
||||
[
|
||||
[0, 0, 1],
|
||||
[0, 1, 0],
|
||||
[1, 0, 0]
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def band4_recomb
|
||||
@band4_recomb ||= Vips::Image.new_from_array(
|
||||
[
|
||||
[0, 0, 1, 0],
|
||||
[0, 1, 0, 0],
|
||||
[1, 0, 0, 0]
|
||||
]
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LoadIco
|
||||
BI_RGB = 0
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(ico_bytes)
|
||||
io = StringIO.new(ico_bytes)
|
||||
_reserved, type, count = io.read(6)&.unpack('S<S<S<')
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless type == 1 && count&.positive?
|
||||
|
||||
ico_entries_parsed = []
|
||||
|
||||
count.times do
|
||||
entry_bytes = io.read(16)
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless entry_bytes && entry_bytes.bytesize == 16
|
||||
|
||||
width_byte, height_byte, _num_colors_palette, _rsvd_entry, _planes_icon_entry, bpp_icon_entry,
|
||||
img_data_size, img_data_offset = entry_bytes.unpack('CCCCS<S<L<L<')
|
||||
|
||||
width = width_byte.zero? ? 256 : width_byte
|
||||
height = height_byte.zero? ? 256 : height_byte
|
||||
sort_bpp = bpp_icon_entry.zero? ? 32 : bpp_icon_entry
|
||||
|
||||
ico_entries_parsed << {
|
||||
width: width, height: height,
|
||||
sort_bpp: sort_bpp,
|
||||
size: img_data_size, offset: img_data_offset
|
||||
}
|
||||
end
|
||||
|
||||
best_entry = ico_entries_parsed.min_by { |e| [-e[:width] * e[:height], -e[:sort_bpp]] }
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless best_entry
|
||||
|
||||
io.seek(best_entry[:offset])
|
||||
image_data_bytes = io.read(best_entry[:size])
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
|
||||
|
||||
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless image
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
def load_image_entry(image_data_bytes, ico_entry_width, ico_entry_height)
|
||||
dib_io = StringIO.new(image_data_bytes)
|
||||
|
||||
dib_header_size_arr = dib_io.read(4)&.unpack('L<')
|
||||
return nil unless dib_header_size_arr
|
||||
|
||||
dib_header_size = dib_header_size_arr.first
|
||||
return nil unless dib_header_size && dib_header_size >= 40
|
||||
|
||||
dib_params_bytes = dib_io.read(36)
|
||||
return nil unless dib_params_bytes && dib_params_bytes.bytesize == 36
|
||||
|
||||
dib_width, dib_actual_height_field, dib_planes, dib_bpp,
|
||||
dib_compression, _dib_image_size, _xpels, _ypels,
|
||||
dib_clr_used, _dib_clr_important = dib_params_bytes.unpack('l<l<S<S<L<L<l<l<L<L<')
|
||||
|
||||
return nil unless dib_width && dib_actual_height_field && dib_planes && dib_bpp && dib_compression && dib_clr_used
|
||||
return nil unless dib_width == ico_entry_width
|
||||
|
||||
image_pixel_height = ico_entry_height
|
||||
|
||||
expected_dib_height_no_mask = image_pixel_height
|
||||
expected_dib_height_with_mask = image_pixel_height * 2
|
||||
actual_dib_pixel_rows_abs = dib_actual_height_field.abs
|
||||
|
||||
unless actual_dib_pixel_rows_abs == expected_dib_height_no_mask ||
|
||||
actual_dib_pixel_rows_abs == expected_dib_height_with_mask
|
||||
return nil
|
||||
end
|
||||
|
||||
return nil unless dib_planes == 1
|
||||
return nil unless dib_compression == BI_RGB
|
||||
return nil unless [1, 4, 8, 24, 32].include?(dib_bpp)
|
||||
|
||||
has_and_mask = (actual_dib_pixel_rows_abs == expected_dib_height_with_mask) && (dib_bpp < 32)
|
||||
|
||||
dib_io.seek(dib_header_size, IO::SEEK_SET)
|
||||
|
||||
palette = []
|
||||
if dib_bpp <= 8
|
||||
num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
|
||||
num_palette_entries.times do
|
||||
palette_color_bytes = dib_io.read(4)
|
||||
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
|
||||
|
||||
b, g, r, _a_reserved = palette_color_bytes.unpack('CCCC')
|
||||
palette << [r, g, b, 255]
|
||||
end
|
||||
end
|
||||
|
||||
xor_mask_data_offset = dib_io.pos
|
||||
xor_scanline_stride = (((dib_width * dib_bpp) + 31) / 32) * 4
|
||||
|
||||
and_mask_data_offset = 0
|
||||
and_scanline_stride = 0
|
||||
if has_and_mask
|
||||
and_mask_data_offset = xor_mask_data_offset + (image_pixel_height * xor_scanline_stride)
|
||||
and_scanline_stride = (((dib_width * 1) + 31) / 32) * 4
|
||||
end
|
||||
|
||||
flat_rgba_pixels = []
|
||||
|
||||
(0...image_pixel_height).each do |y_row|
|
||||
y_dib_row = image_pixel_height - 1 - y_row
|
||||
|
||||
dib_io.seek(xor_mask_data_offset + (y_dib_row * xor_scanline_stride))
|
||||
xor_scanline_bytes = dib_io.read(xor_scanline_stride)
|
||||
min_xor_bytes_needed = ((dib_width * dib_bpp) + 7) / 8
|
||||
return nil unless xor_scanline_bytes && xor_scanline_bytes.bytesize >= min_xor_bytes_needed
|
||||
|
||||
and_mask_bits_for_row = []
|
||||
if has_and_mask
|
||||
dib_io.seek(and_mask_data_offset + (y_dib_row * and_scanline_stride))
|
||||
and_mask_scanline_bytes = dib_io.read(and_scanline_stride)
|
||||
min_and_bytes_needed = ((dib_width * 1) + 7) / 8
|
||||
return nil unless and_mask_scanline_bytes && and_mask_scanline_bytes.bytesize >= min_and_bytes_needed
|
||||
|
||||
(0...dib_width).each do |x_pixel|
|
||||
byte_index = x_pixel / 8
|
||||
bit_index_in_byte = 7 - (x_pixel % 8)
|
||||
byte_val = and_mask_scanline_bytes.getbyte(byte_index)
|
||||
and_mask_bits_for_row << ((byte_val >> bit_index_in_byte) & 1)
|
||||
end
|
||||
end
|
||||
|
||||
(0...dib_width).each do |x_pixel|
|
||||
r = 0
|
||||
g = 0
|
||||
b = 0
|
||||
a = 255
|
||||
|
||||
case dib_bpp
|
||||
when 32
|
||||
offset = x_pixel * 4
|
||||
blue = xor_scanline_bytes.getbyte(offset)
|
||||
green = xor_scanline_bytes.getbyte(offset + 1)
|
||||
red = xor_scanline_bytes.getbyte(offset + 2)
|
||||
alpha_val = xor_scanline_bytes.getbyte(offset + 3)
|
||||
r = red
|
||||
g = green
|
||||
b = blue
|
||||
a = alpha_val
|
||||
when 24
|
||||
offset = x_pixel * 3
|
||||
blue = xor_scanline_bytes.getbyte(offset)
|
||||
green = xor_scanline_bytes.getbyte(offset + 1)
|
||||
red = xor_scanline_bytes.getbyte(offset + 2)
|
||||
r = red
|
||||
g = green
|
||||
b = blue
|
||||
when 8
|
||||
idx = xor_scanline_bytes.getbyte(x_pixel)
|
||||
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
|
||||
r = r_p
|
||||
g = g_p
|
||||
b = b_p
|
||||
a = a_p
|
||||
when 4
|
||||
byte_val = xor_scanline_bytes.getbyte(x_pixel / 2)
|
||||
idx = (x_pixel.even? ? (byte_val >> 4) : (byte_val & 0x0F))
|
||||
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
|
||||
r = r_p
|
||||
g = g_p
|
||||
b = b_p
|
||||
a = a_p
|
||||
when 1
|
||||
byte_val = xor_scanline_bytes.getbyte(x_pixel / 8)
|
||||
idx = (byte_val >> (7 - (x_pixel % 8))) & 1
|
||||
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
|
||||
r = r_p
|
||||
g = g_p
|
||||
b = b_p
|
||||
a = a_p
|
||||
end
|
||||
|
||||
if has_and_mask && !and_mask_bits_for_row.empty?
|
||||
a = and_mask_bits_for_row[x_pixel] == 1 ? 0 : 255
|
||||
end
|
||||
flat_rgba_pixels.push(r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
pixel_data_string = flat_rgba_pixels.pack('C*')
|
||||
|
||||
expected_bytes = dib_width * image_pixel_height * 4
|
||||
|
||||
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
|
||||
|
||||
Vips::Image.new_from_memory(
|
||||
pixel_data_string,
|
||||
dib_width,
|
||||
image_pixel_height,
|
||||
4,
|
||||
:uchar
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Pdfium
|
||||
extend FFI::Library
|
||||
|
||||
LIB_NAME = 'pdfium'
|
||||
|
||||
begin
|
||||
ffi_lib case FFI::Platform::OS
|
||||
when 'darwin'
|
||||
[
|
||||
"lib#{LIB_NAME}.dylib",
|
||||
'/Applications/LibreOffice.app/Contents/Frameworks/libpdfiumlo.dylib'
|
||||
]
|
||||
else
|
||||
"lib#{LIB_NAME}.so"
|
||||
end
|
||||
rescue LoadError => e
|
||||
raise "Could not load libpdfium library. Make sure it's installed and in your library path. Error: #{e.message}"
|
||||
end
|
||||
|
||||
typedef :pointer, :FPDF_STRING
|
||||
typedef :pointer, :FPDF_DOCUMENT
|
||||
typedef :pointer, :FPDF_PAGE
|
||||
typedef :pointer, :FPDF_BITMAP
|
||||
typedef :pointer, :FPDF_FORMHANDLE
|
||||
|
||||
MAX_SIZE = 32_767
|
||||
|
||||
FPDF_ANNOT = 0x01
|
||||
FPDF_LCD_TEXT = 0x02
|
||||
FPDF_NO_NATIVETEXT = 0x04
|
||||
FPDF_GRAYSCALE = 0x08
|
||||
FPDF_REVERSE_BYTE_ORDER = 0x10
|
||||
FPDF_RENDER_LIMITEDIMAGECACHE = 0x200
|
||||
FPDF_RENDER_FORCEHALFTONE = 0x400
|
||||
FPDF_PRINTING = 0x800
|
||||
|
||||
# rubocop:disable Naming/ClassAndModuleCamelCase
|
||||
class FPDF_LIBRARY_CONFIG < FFI::Struct
|
||||
layout :version, :int,
|
||||
:m_pUserFontPaths, :pointer,
|
||||
:m_pIsolate, :pointer,
|
||||
:m_v8EmbedderSlot, :uint,
|
||||
:m_pPlatform, :pointer,
|
||||
:m_RendererType, :int
|
||||
end
|
||||
# rubocop:enable Naming/ClassAndModuleCamelCase
|
||||
|
||||
attach_function :FPDF_InitLibraryWithConfig, [:pointer], :void
|
||||
attach_function :FPDF_DestroyLibrary, [], :void
|
||||
|
||||
attach_function :FPDF_LoadDocument, %i[string FPDF_STRING], :FPDF_DOCUMENT
|
||||
attach_function :FPDF_LoadMemDocument, %i[pointer int FPDF_STRING], :FPDF_DOCUMENT
|
||||
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
|
||||
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
|
||||
attach_function :FPDF_GetLastError, [], :ulong
|
||||
|
||||
attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE
|
||||
attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void
|
||||
attach_function :FPDF_GetPageWidthF, [:FPDF_PAGE], :float
|
||||
attach_function :FPDF_GetPageHeightF, [:FPDF_PAGE], :float
|
||||
|
||||
attach_function :FPDFBitmap_Create, %i[int int int], :FPDF_BITMAP
|
||||
attach_function :FPDFBitmap_CreateEx, %i[int int int pointer int], :FPDF_BITMAP
|
||||
attach_function :FPDFBitmap_Destroy, [:FPDF_BITMAP], :void
|
||||
attach_function :FPDFBitmap_GetBuffer, [:FPDF_BITMAP], :pointer
|
||||
attach_function :FPDFBitmap_GetWidth, [:FPDF_BITMAP], :int
|
||||
attach_function :FPDFBitmap_GetHeight, [:FPDF_BITMAP], :int
|
||||
attach_function :FPDFBitmap_GetStride, [:FPDF_BITMAP], :int
|
||||
attach_function :FPDFBitmap_FillRect, %i[FPDF_BITMAP int int int int ulong], :void
|
||||
|
||||
attach_function :FPDF_RenderPageBitmap, %i[FPDF_BITMAP FPDF_PAGE int int int int int int], :void
|
||||
|
||||
typedef :int, :FPDF_BOOL
|
||||
typedef :pointer, :IPDF_JSPLATFORM
|
||||
|
||||
# rubocop:disable Naming/ClassAndModuleCamelCase
|
||||
class FPDF_FORMFILLINFO_V2 < FFI::Struct
|
||||
layout :version, :int,
|
||||
:Release, :pointer,
|
||||
:FFI_Invalidate, :pointer,
|
||||
:FFI_OutputSelectedRect, :pointer,
|
||||
:FFI_SetCursor, :pointer,
|
||||
:FFI_SetTimer, :pointer,
|
||||
:FFI_KillTimer, :pointer,
|
||||
:FFI_GetLocalTime, :pointer,
|
||||
:FFI_OnChange, :pointer,
|
||||
:FFI_GetPage, :pointer,
|
||||
:FFI_GetCurrentPage, :pointer,
|
||||
:FFI_GetRotation, :pointer,
|
||||
:FFI_ExecuteNamedAction, :pointer,
|
||||
:FFI_SetTextFieldFocus, :pointer,
|
||||
:FFI_DoURIAction, :pointer,
|
||||
:FFI_DoGoToAction, :pointer,
|
||||
:m_pJsPlatform, :IPDF_JSPLATFORM,
|
||||
:xfa_disabled, :FPDF_BOOL,
|
||||
:FFI_DisplayCaret, :pointer,
|
||||
:FFI_GetCurrentPageIndex, :pointer,
|
||||
:FFI_SetCurrentPage, :pointer,
|
||||
:FFI_GotoURL, :pointer,
|
||||
:FFI_GetPageViewRect, :pointer,
|
||||
:FFI_PageEvent, :pointer,
|
||||
:FFI_PopupMenu, :pointer,
|
||||
:FFI_OpenFile, :pointer,
|
||||
:FFI_EmailTo, :pointer,
|
||||
:FFI_UploadTo, :pointer,
|
||||
:FFI_GetPlatform, :pointer,
|
||||
:FFI_GetLanguage, :pointer,
|
||||
:FFI_DownloadFromURL, :pointer,
|
||||
:FFI_PostRequestURL, :pointer,
|
||||
:FFI_PutRequestURL, :pointer,
|
||||
:FFI_OnFocusChange, :pointer,
|
||||
:FFI_DoURIActionWithKeyboardModifier, :pointer
|
||||
end
|
||||
# rubocop:enable Naming/ClassAndModuleCamelCase
|
||||
|
||||
attach_function :FPDFDOC_InitFormFillEnvironment, %i[FPDF_DOCUMENT pointer], :FPDF_FORMHANDLE
|
||||
attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void
|
||||
attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void
|
||||
|
||||
FPDF_ERR_SUCCESS = 0
|
||||
FPDF_ERR_UNKNOWN = 1
|
||||
FPDF_ERR_FILE = 2
|
||||
FPDF_ERR_FORMAT = 3
|
||||
FPDF_ERR_PASSWORD = 4
|
||||
FPDF_ERR_SECURITY = 5
|
||||
FPDF_ERR_PAGE = 6
|
||||
|
||||
PDFIUM_ERRORS = {
|
||||
FPDF_ERR_SUCCESS => 'Success',
|
||||
FPDF_ERR_UNKNOWN => 'Unknown error',
|
||||
FPDF_ERR_FILE => 'Error open file',
|
||||
FPDF_ERR_FORMAT => 'Invalid format',
|
||||
FPDF_ERR_PASSWORD => 'Incorrect password',
|
||||
FPDF_ERR_SECURITY => 'Security scheme error',
|
||||
FPDF_ERR_PAGE => 'Page not found'
|
||||
}.freeze
|
||||
|
||||
class PdfiumError < StandardError; end
|
||||
|
||||
def self.error_message(code)
|
||||
PDFIUM_ERRORS[code] || "Unknown error code: #{code}"
|
||||
end
|
||||
|
||||
def self.check_last_error(context_message = 'PDFium operation failed')
|
||||
error_code = FPDF_GetLastError()
|
||||
|
||||
return if error_code == FPDF_ERR_SUCCESS
|
||||
|
||||
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
|
||||
end
|
||||
|
||||
class Document
|
||||
attr_reader :document_ptr, :form_handle
|
||||
|
||||
def initialize(document_ptr, source_buffer = nil)
|
||||
raise ArgumentError, 'document_ptr cannot be nil' if document_ptr.nil? || document_ptr.null?
|
||||
|
||||
@document_ptr = document_ptr
|
||||
|
||||
@pages = {}
|
||||
@closed = false
|
||||
@source_buffer = source_buffer
|
||||
@form_handle = FFI::Pointer::NULL
|
||||
@form_fill_info_mem = FFI::Pointer::NULL
|
||||
|
||||
init_form_fill_environment
|
||||
end
|
||||
|
||||
def init_form_fill_environment
|
||||
return if @document_ptr.null?
|
||||
|
||||
@form_fill_info_mem = FFI::MemoryPointer.new(FPDF_FORMFILLINFO_V2.size)
|
||||
|
||||
form_fill_info_struct = FPDF_FORMFILLINFO_V2.new(@form_fill_info_mem)
|
||||
form_fill_info_struct[:version] = 2
|
||||
|
||||
@form_handle = Pdfium.FPDFDOC_InitFormFillEnvironment(@document_ptr, @form_fill_info_mem)
|
||||
end
|
||||
|
||||
def page_count
|
||||
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
|
||||
end
|
||||
|
||||
def self.open_file(file_path, password = nil)
|
||||
doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password)
|
||||
|
||||
if doc_ptr.null?
|
||||
Pdfium.check_last_error("Failed to load document from file '#{file_path}'")
|
||||
|
||||
raise PdfiumError, "Failed to load document from file '#{file_path}', pointer is NULL."
|
||||
end
|
||||
|
||||
doc = new(doc_ptr)
|
||||
|
||||
return doc unless block_given?
|
||||
|
||||
begin
|
||||
yield doc
|
||||
ensure
|
||||
doc.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.open_bytes(bytes, password = nil)
|
||||
buffer = FFI::MemoryPointer.new(:char, bytes.bytesize)
|
||||
buffer.put_bytes(0, bytes)
|
||||
|
||||
doc_ptr = Pdfium.FPDF_LoadMemDocument(buffer, bytes.bytesize, password)
|
||||
|
||||
if doc_ptr.null?
|
||||
Pdfium.check_last_error('Failed to load document from memory')
|
||||
|
||||
raise PdfiumError, 'Failed to load document from memory, pointer is NULL.'
|
||||
end
|
||||
|
||||
doc = new(doc_ptr, buffer)
|
||||
|
||||
return doc unless block_given?
|
||||
|
||||
begin
|
||||
yield doc
|
||||
ensure
|
||||
doc.close
|
||||
end
|
||||
end
|
||||
|
||||
def closed?
|
||||
@closed
|
||||
end
|
||||
|
||||
def ensure_not_closed!
|
||||
raise PdfiumError, 'Document is closed.' if closed?
|
||||
end
|
||||
|
||||
def get_page(page_index)
|
||||
ensure_not_closed!
|
||||
|
||||
unless page_index.is_a?(Integer) && page_index >= 0 && page_index < page_count
|
||||
raise PdfiumError, "Page index #{page_index} out of range (0..#{page_count - 1})"
|
||||
end
|
||||
|
||||
@pages[page_index] ||= Page.new(self, page_index)
|
||||
end
|
||||
|
||||
def close
|
||||
return if closed?
|
||||
|
||||
@pages.each_value { |page| page.close unless page.closed? }
|
||||
@pages.clear
|
||||
|
||||
unless @form_handle.null?
|
||||
Pdfium.FPDFDOC_ExitFormFillEnvironment(@form_handle)
|
||||
|
||||
@form_handle = FFI::Pointer::NULL
|
||||
end
|
||||
|
||||
if @form_fill_info_mem && !@form_fill_info_mem.null?
|
||||
@form_fill_info_mem.free
|
||||
@form_fill_info_mem = FFI::Pointer::NULL
|
||||
end
|
||||
|
||||
Pdfium.FPDF_CloseDocument(@document_ptr) unless @document_ptr.null?
|
||||
|
||||
@document_ptr = FFI::Pointer::NULL
|
||||
@source_buffer = nil
|
||||
|
||||
@closed = true
|
||||
end
|
||||
end
|
||||
|
||||
class Page
|
||||
attr_reader :document, :page_index, :page_ptr
|
||||
|
||||
def initialize(document, page_index)
|
||||
raise ArgumentError, 'Document object is required' unless document.is_a?(Pdfium::Document)
|
||||
|
||||
@document = document
|
||||
@document.ensure_not_closed!
|
||||
|
||||
@page_index = page_index
|
||||
|
||||
@page_ptr = Pdfium.FPDF_LoadPage(document.document_ptr, page_index)
|
||||
|
||||
if @page_ptr.null?
|
||||
Pdfium.check_last_error("Failed to load page #{page_index}")
|
||||
|
||||
raise PdfiumError, "Failed to load page #{page_index}, pointer is NULL."
|
||||
end
|
||||
|
||||
@closed = false
|
||||
end
|
||||
|
||||
def width
|
||||
@width ||= Pdfium.FPDF_GetPageWidthF(@page_ptr)
|
||||
end
|
||||
|
||||
def height
|
||||
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
|
||||
end
|
||||
|
||||
def closed?
|
||||
@closed
|
||||
end
|
||||
|
||||
def form_handle
|
||||
@document.form_handle
|
||||
end
|
||||
|
||||
def ensure_not_closed!
|
||||
raise PdfiumError, 'Page is closed.' if closed?
|
||||
|
||||
@document.ensure_not_closed!
|
||||
end
|
||||
|
||||
def render_to_bitmap(width: nil, height: nil, scale: nil, background_color: 0xFFFFFFFF,
|
||||
flags: FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_NO_NATIVETEXT | FPDF_REVERSE_BYTE_ORDER)
|
||||
ensure_not_closed!
|
||||
|
||||
render_width, render_height = calculate_render_dimensions(width, height, scale)
|
||||
|
||||
bitmap_ptr = Pdfium.FPDFBitmap_Create(render_width, render_height, 1)
|
||||
|
||||
if bitmap_ptr.null?
|
||||
Pdfium.check_last_error('Failed to create bitmap (potential pre-existing error)')
|
||||
|
||||
raise PdfiumError, 'Failed to create bitmap (FPDFBitmap_Create returned NULL)'
|
||||
end
|
||||
|
||||
Pdfium.FPDFBitmap_FillRect(bitmap_ptr, 0, 0, render_width, render_height, background_color)
|
||||
|
||||
Pdfium.FPDF_RenderPageBitmap(bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
|
||||
|
||||
Pdfium.check_last_error('Failed to render page to bitmap')
|
||||
|
||||
unless form_handle.null?
|
||||
Pdfium.FPDF_FFLDraw(form_handle, bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
|
||||
|
||||
Pdfium.check_last_error('Call to FPDF_FFLDraw completed (check for rendering issues if any)')
|
||||
end
|
||||
|
||||
buffer_ptr = Pdfium.FPDFBitmap_GetBuffer(bitmap_ptr)
|
||||
stride = Pdfium.FPDFBitmap_GetStride(bitmap_ptr)
|
||||
|
||||
bitmap_data = buffer_ptr.read_bytes(stride * render_height)
|
||||
|
||||
[bitmap_data, render_width, render_height]
|
||||
ensure
|
||||
Pdfium.FPDFBitmap_Destroy(bitmap_ptr) if bitmap_ptr && !bitmap_ptr.null?
|
||||
end
|
||||
|
||||
def close
|
||||
return if closed?
|
||||
|
||||
Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null?
|
||||
|
||||
@page_ptr = FFI::Pointer::NULL
|
||||
|
||||
@closed = true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_render_dimensions(width_param, height_param, scale_param)
|
||||
if scale_param
|
||||
render_width = (width * scale_param).round
|
||||
render_height = (height * scale_param).round
|
||||
elsif width_param || height_param
|
||||
if width_param && height_param
|
||||
render_width = width_param
|
||||
render_height = height_param
|
||||
elsif width_param
|
||||
scale_factor = width_param.to_f / width
|
||||
render_width = width_param
|
||||
render_height = (height * scale_factor).round
|
||||
else
|
||||
scale_factor = height_param.to_f / height
|
||||
render_width = (width * scale_factor).round
|
||||
render_height = height_param
|
||||
end
|
||||
else
|
||||
render_width = width.to_i
|
||||
render_height = height.to_i
|
||||
end
|
||||
|
||||
[render_width.clamp(1, MAX_SIZE), render_height.clamp(1, MAX_SIZE)]
|
||||
end
|
||||
end
|
||||
|
||||
def self.initialize_library
|
||||
config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size)
|
||||
|
||||
config_struct = FPDF_LIBRARY_CONFIG.new(config_mem)
|
||||
config_struct[:version] = 2
|
||||
config_struct[:m_pUserFontPaths] = FFI::Pointer::NULL
|
||||
config_struct[:m_pIsolate] = FFI::Pointer::NULL
|
||||
config_struct[:m_v8EmbedderSlot] = 0
|
||||
|
||||
FPDF_InitLibraryWithConfig(config_mem)
|
||||
end
|
||||
|
||||
def self.cleanup_library
|
||||
FPDF_DestroyLibrary()
|
||||
end
|
||||
|
||||
initialize_library
|
||||
|
||||
at_exit do
|
||||
cleanup_library
|
||||
end
|
||||
end
|
||||
@@ -120,6 +120,8 @@ module Submissions
|
||||
|
||||
email = email.to_s.tr('/', ',')
|
||||
|
||||
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
|
||||
|
||||
return email.downcase if email.include?(',') ||
|
||||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) ||
|
||||
email.exclude?('.')
|
||||
|
||||
@@ -31,7 +31,9 @@ module Submissions
|
||||
def call(submission)
|
||||
account = submission.account
|
||||
|
||||
I18n.with_locale(account.locale) do
|
||||
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
|
||||
|
||||
I18n.with_locale(last_submitter.metadata.fetch('lang', account.locale)) do
|
||||
document = build_audit_trail(submission)
|
||||
|
||||
pkcs = Accounts.load_signing_pkcs(account)
|
||||
@@ -41,8 +43,6 @@ module Submissions
|
||||
|
||||
document.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})"
|
||||
|
||||
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
|
||||
|
||||
if pkcs
|
||||
sign_params = {
|
||||
reason: sign_reason,
|
||||
@@ -309,7 +309,7 @@ module Submissions
|
||||
|
||||
image =
|
||||
begin
|
||||
Vips::Image.new_from_buffer(attachment.download, '').autorot
|
||||
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
|
||||
rescue Vips::Error
|
||||
next unless attachment.content_type.starts_with?('image/')
|
||||
next if attachment.byte_size.zero?
|
||||
@@ -322,7 +322,7 @@ module Submissions
|
||||
resized_image = image.resize([scale, 1].min)
|
||||
io = StringIO.new(resized_image.write_to_buffer('.png'))
|
||||
|
||||
width = field['type'] == 'initials' ? 100 : 200
|
||||
width = field['type'] == 'initials' ? 50 : 200
|
||||
height = resized_image.height * (width.to_f / resized_image.width)
|
||||
|
||||
if height > MAX_IMAGE_HEIGHT
|
||||
|
||||
@@ -11,6 +11,9 @@ module Submissions
|
||||
'Helvetica'
|
||||
end
|
||||
|
||||
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
|
||||
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
|
||||
|
||||
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
|
||||
FONT_BOLD_PATH
|
||||
else
|
||||
@@ -149,7 +152,8 @@ module Submissions
|
||||
TESTING_FOOTER
|
||||
end
|
||||
else
|
||||
"#{I18n.t('document_id', locale: submitter.account.locale)}: #{document_id}"
|
||||
"#{I18n.t('document_id',
|
||||
locale: submitter.metadata.fetch('lang', submitter.account.locale))}: #{document_id}"
|
||||
end
|
||||
|
||||
text = HexaPDF::Layout::TextFragment.create(
|
||||
@@ -179,6 +183,8 @@ module Submissions
|
||||
|
||||
with_headings = find_last_submitter(submitter.submission, submitter:).blank? if with_headings.nil?
|
||||
|
||||
locale = submitter.metadata.fetch('lang', account.locale)
|
||||
|
||||
submitter.submission.template_fields.each do |field|
|
||||
next if field['type'] == 'heading' && !with_headings
|
||||
next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading'
|
||||
@@ -250,14 +256,12 @@ module Submissions
|
||||
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
|
||||
attachments_data_cache[attachment.uuid] ||= attachment.download
|
||||
|
||||
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
|
||||
image = load_vips_image(attachment, attachments_data_cache).autorot
|
||||
|
||||
reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
|
||||
|
||||
reason_string =
|
||||
I18n.with_locale(submitter.account.locale) do
|
||||
I18n.with_locale(locale) do
|
||||
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
|
||||
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
|
||||
"#{I18n.l(attachment.created_at.in_time_zone(submitter.account.timezone), format: :long)} " \
|
||||
@@ -360,11 +364,9 @@ module Submissions
|
||||
when 'image', 'signature', 'initials', 'stamp'
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
|
||||
attachments_data_cache[attachment.uuid] ||= attachment.download
|
||||
|
||||
image =
|
||||
begin
|
||||
Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
|
||||
load_vips_image(attachment, attachments_data_cache).autorot
|
||||
rescue Vips::Error
|
||||
next unless attachment.content_type.starts_with?('image/')
|
||||
next if attachment.byte_size.zero?
|
||||
@@ -443,7 +445,7 @@ module Submissions
|
||||
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
|
||||
|
||||
option_name = option['value'].presence
|
||||
option_name ||= "#{I18n.t('option', locale: account.locale)} #{field['options'].index(option) + 1}"
|
||||
option_name ||= "#{I18n.t('option', locale: locale)} #{field['options'].index(option) + 1}"
|
||||
|
||||
value = Array.wrap(value).include?(option_name)
|
||||
end
|
||||
@@ -510,7 +512,7 @@ module Submissions
|
||||
end
|
||||
else
|
||||
if field['type'] == 'date'
|
||||
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
|
||||
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale)
|
||||
end
|
||||
|
||||
value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'
|
||||
@@ -736,14 +738,16 @@ module Submissions
|
||||
|
||||
page = pdf.pages.add
|
||||
|
||||
scale = [A4_SIZE.first / attachment.metadata['width'].to_f,
|
||||
A4_SIZE.last / attachment.metadata['height'].to_f].min
|
||||
image = attachment.preview_images.first
|
||||
|
||||
page.box.width = attachment.metadata['width'] * scale
|
||||
page.box.height = attachment.metadata['height'] * scale
|
||||
scale = [A4_SIZE.first / image.metadata['width'].to_f,
|
||||
A4_SIZE.last / image.metadata['height'].to_f].min
|
||||
|
||||
page.box.width = image.metadata['width'] * scale
|
||||
page.box.height = image.metadata['height'] * scale
|
||||
|
||||
page.canvas.image(
|
||||
StringIO.new(attachment.preview_images.first.download),
|
||||
StringIO.new(image.download),
|
||||
at: [0, 0],
|
||||
width: page.box.width,
|
||||
height: page.box.height
|
||||
@@ -804,6 +808,20 @@ module Submissions
|
||||
[]
|
||||
end
|
||||
|
||||
def load_vips_image(attachment, cache = {})
|
||||
cache[attachment.uuid] ||= attachment.download
|
||||
|
||||
data = cache[attachment.uuid]
|
||||
|
||||
if ICO_REGEXP.match?(attachment.content_type)
|
||||
LoadIco.call(data)
|
||||
elsif BMP_REGEXP.match?(attachment.content_type)
|
||||
LoadBmp.call(data)
|
||||
else
|
||||
Vips::Image.new_from_buffer(data, '')
|
||||
end
|
||||
end
|
||||
|
||||
def h
|
||||
Rails.application.routes.url_helpers
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@ module Templates
|
||||
template = original_template.account.templates.new
|
||||
|
||||
template.external_id = external_id
|
||||
template.shared_link = original_template.shared_link
|
||||
template.author = author
|
||||
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ module Templates
|
||||
FORMAT = '.png'
|
||||
ATTACHMENT_NAME = 'preview_images'
|
||||
|
||||
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
|
||||
PDF_CONTENT_TYPE = 'application/pdf'
|
||||
CONCURRENCY = 2
|
||||
Q = 95
|
||||
@@ -38,7 +39,13 @@ module Templates
|
||||
def generate_preview_image(attachment, data)
|
||||
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
|
||||
|
||||
image = Vips::Image.new_from_buffer(data, '')
|
||||
image =
|
||||
if BMP_REGEXP.match?(attachment.content_type)
|
||||
LoadBmp.call(data)
|
||||
else
|
||||
Vips::Image.new_from_buffer(data, '')
|
||||
end
|
||||
|
||||
image = image.autorot.resize(MAX_WIDTH / image.width.to_f)
|
||||
|
||||
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
|
||||
@@ -77,11 +84,13 @@ module Templates
|
||||
end
|
||||
|
||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
|
||||
doc = Pdfium::Document.open_bytes(data)
|
||||
|
||||
pool = Concurrent::FixedThreadPool.new(concurrency)
|
||||
|
||||
promises =
|
||||
range.map do |page_number|
|
||||
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) }
|
||||
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(doc, page_number) }
|
||||
end
|
||||
|
||||
Concurrent::Promise.zip(*promises).value!.each do |blob|
|
||||
@@ -95,31 +104,44 @@ module Templates
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
pool.kill
|
||||
ensure
|
||||
doc&.close
|
||||
pool&.kill
|
||||
end
|
||||
|
||||
def build_and_upload_blob(data, page_number)
|
||||
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
|
||||
page = page.resize(MAX_WIDTH / page.width.to_f)
|
||||
def build_and_upload_blob(doc, page_number, format = FORMAT)
|
||||
doc_page = doc.get_page(page_number)
|
||||
|
||||
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
|
||||
data, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
|
||||
|
||||
io = StringIO.new(page.write_to_buffer(FORMAT, compression: 7, filter: 0, bitdepth:,
|
||||
palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0))
|
||||
page = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
|
||||
|
||||
page = page.copy(interpretation: :srgb)
|
||||
|
||||
data =
|
||||
if format == FORMAT
|
||||
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
|
||||
|
||||
page.write_to_buffer(format, compression: 7, filter: 0, bitdepth:,
|
||||
palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0)
|
||||
else
|
||||
page.write_to_buffer(format, interlace: true, Q: JPEG_Q)
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.new(
|
||||
filename: "#{page_number}#{FORMAT}",
|
||||
filename: "#{page_number}#{format}",
|
||||
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
|
||||
)
|
||||
|
||||
blob.upload(io)
|
||||
blob.upload(StringIO.new(data))
|
||||
|
||||
blob
|
||||
rescue Vips::Error => e
|
||||
rescue Vips::Error, Pdfium::PdfiumError => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
nil
|
||||
ensure
|
||||
doc_page&.close
|
||||
end
|
||||
|
||||
def maybe_flatten_form(data, pdf)
|
||||
@@ -162,35 +184,19 @@ module Templates
|
||||
end
|
||||
|
||||
def generate_pdf_preview_from_file(attachment, file_path, page_number)
|
||||
io = StringIO.new
|
||||
doc = Pdfium::Document.open_file(file_path)
|
||||
|
||||
command = [
|
||||
'pdftocairo', '-jpeg', '-jpegopt', "progressive=y,quality=#{JPEG_Q},optimize=y",
|
||||
'-scale-to-x', MAX_WIDTH, '-scale-to-y', '-1',
|
||||
'-r', DPI, '-f', page_number + 1, '-l', page_number + 1,
|
||||
'-singlefile', Shellwords.escape(file_path), '-'
|
||||
].join(' ')
|
||||
|
||||
Open3.popen3(command) do |_, stdout, _, _|
|
||||
io.write(stdout.read)
|
||||
|
||||
io.rewind
|
||||
end
|
||||
|
||||
page = Vips::Image.new_from_buffer(io.read, '')
|
||||
|
||||
io.rewind
|
||||
blob = build_and_upload_blob(doc, page_number, '.jpeg')
|
||||
|
||||
ApplicationRecord.no_touching do
|
||||
ActiveStorage::Attachment.create!(
|
||||
blob: ActiveStorage::Blob.create_and_upload!(
|
||||
io:, filename: "#{page_number}.jpg",
|
||||
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
|
||||
),
|
||||
blob: blob,
|
||||
name: ATTACHMENT_NAME,
|
||||
record: attachment
|
||||
)
|
||||
end
|
||||
ensure
|
||||
doc&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ module Templates
|
||||
only: %w[
|
||||
id archived_at fields name preferences schema
|
||||
slug source submitters created_at updated_at
|
||||
author_id external_id folder_id
|
||||
author_id external_id folder_id shared_link
|
||||
],
|
||||
methods: %i[application_key folder_name],
|
||||
include: { author: { only: %i[id email first_name last_name] } }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :template_access do
|
||||
template
|
||||
user
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,7 @@ FactoryBot.define do
|
||||
%w[text date checkbox radio signature number multiple select initials image file stamp cells phone payment]
|
||||
end
|
||||
except_field_types { [] }
|
||||
private_access_user { nil }
|
||||
end
|
||||
|
||||
after(:create) do |template, ev|
|
||||
@@ -343,5 +344,17 @@ FactoryBot.define do
|
||||
|
||||
template.save!
|
||||
end
|
||||
|
||||
trait :with_admin_only_access do
|
||||
after(:create) do |template|
|
||||
create(:template_access, template:, user_id: TemplateAccess::ADMIN_USER_ID)
|
||||
end
|
||||
end
|
||||
|
||||
trait :with_private_access do
|
||||
after(:create) do |template, ev|
|
||||
create(:template_access, template:, user: ev.private_access_user || template.author)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ProcessSubmitterCompletionJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendFormCompletedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendFormDeclinedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendFormStartedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendFormViewedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendSubmissionArchivedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendSubmissionCompletedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendSubmissionCreatedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendTemplateCreatedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendTemplateUpdatedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Params::BaseValidator do
|
||||
let(:validator) { described_class.new({}) }
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Submission API', type: :request do
|
||||
describe 'Submission API' do
|
||||
let(:account) { create(:account, :with_testing_account) }
|
||||
let(:testing_account) { account.testing_accounts.first }
|
||||
let(:author) { create(:user, account:) }
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Submitter API', type: :request do
|
||||
describe 'Submitter API' do
|
||||
let(:account) { create(:account, :with_testing_account) }
|
||||
let(:testing_account) { account.testing_accounts.first }
|
||||
let(:author) { create(:user, account:) }
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Templates API', type: :request do
|
||||
describe 'Templates API' do
|
||||
let(:account) { create(:account, :with_testing_account) }
|
||||
let(:testing_account) { account.testing_accounts.first }
|
||||
let(:author) { create(:user, account:) }
|
||||
@@ -85,13 +83,15 @@ describe 'Templates API', type: :request do
|
||||
end
|
||||
|
||||
describe 'PUT /api/templates' do
|
||||
it 'update a template' do
|
||||
template = create(:template, account:,
|
||||
author:,
|
||||
folder:,
|
||||
external_id: SecureRandom.base58(10),
|
||||
preferences: template_preferences)
|
||||
let(:template) do
|
||||
create(:template, account:,
|
||||
author:,
|
||||
folder:,
|
||||
external_id: SecureRandom.base58(10),
|
||||
preferences: template_preferences)
|
||||
end
|
||||
|
||||
it 'updates a template' do
|
||||
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
name: 'Updated Template Name',
|
||||
external_id: '123456'
|
||||
@@ -108,6 +108,24 @@ describe 'Templates API', type: :request do
|
||||
updated_at: template.updated_at
|
||||
}.to_json))
|
||||
end
|
||||
|
||||
it "enables the template's shared link" do
|
||||
expect do
|
||||
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
shared_link: true
|
||||
}.to_json
|
||||
end.to change { template.reload.shared_link }.from(false).to(true)
|
||||
end
|
||||
|
||||
it "disables the template's shared link" do
|
||||
template.update(shared_link: true)
|
||||
|
||||
expect do
|
||||
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
shared_link: false
|
||||
}.to_json
|
||||
end.to change { template.reload.shared_link }.from(true).to(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/templates/:id' do
|
||||
@@ -208,6 +226,7 @@ describe 'Templates API', type: :request do
|
||||
name: 'sample-document'
|
||||
}
|
||||
],
|
||||
shared_link: template.shared_link,
|
||||
author_id: author.id,
|
||||
archived_at: nil,
|
||||
created_at: template.created_at,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Tools API', type: :request do
|
||||
describe 'Tools API' do
|
||||
let(:account) { create(:account) }
|
||||
let(:author) { create(:user, account:) }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/sample-document.pdf') }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Account Settings' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API Settings' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Dashboard Page' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Email Settings' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'PDF Signature Settings' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Newsletter' do
|
||||
let(:user) { create(:user, account: create(:account)) }
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Notifications Settings' do
|
||||
let(:user) { create(:user, account: create(:account)) }
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Personalization Settings', :js do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Personalization' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Profile Settings' do
|
||||
let(:user) { create(:user, account: create(:account)) }
|
||||
|
||||
@@ -35,6 +33,14 @@ RSpec.describe 'Profile Settings' do
|
||||
expect(user.last_name).to eq('Beckham')
|
||||
expect(user.email).to eq('david.beckham@example.com')
|
||||
end
|
||||
|
||||
it 'does not update if email is invalid' do
|
||||
fill_in 'Email', with: 'devid+test@example'
|
||||
|
||||
all(:button, 'Update')[0].click
|
||||
|
||||
expect(page).to have_content('Email is invalid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when changes password' do
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'App Setup' do
|
||||
let(:form_data) do
|
||||
{
|
||||
@@ -53,6 +51,16 @@ RSpec.describe 'App Setup' do
|
||||
end
|
||||
|
||||
context 'when invalid information' do
|
||||
it 'does not setup the app if the email is invalid' do
|
||||
fill_setup_form(form_data.merge(email: 'bob@example-com'))
|
||||
|
||||
expect do
|
||||
click_button 'Submit'
|
||||
end.not_to(change(User, :count))
|
||||
|
||||
expect(page).to have_content('Email is invalid')
|
||||
end
|
||||
|
||||
it 'does not setup the app if the password is too short' do
|
||||
fill_setup_form(form_data.merge(password: 'pass'))
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Sign In', type: :system do
|
||||
RSpec.describe 'Sign In' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:, email: 'john.dou@example.com', password: 'strong_password') }
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Signing Form', type: :system do
|
||||
RSpec.describe 'Signing Form' do
|
||||
let(:account) { create(:account) }
|
||||
let(:author) { create(:user, account:) }
|
||||
|
||||
context 'when the template form link is opened' do
|
||||
let(:template) { create(:template, account:, author:, except_field_types: %w[phone payment stamp]) }
|
||||
let(:template) do
|
||||
create(:template, shared_link: true, account:, author:, except_field_types: %w[phone payment stamp])
|
||||
end
|
||||
|
||||
before do
|
||||
visit start_form_path(slug: template.slug)
|
||||
end
|
||||
|
||||
it 'shows the email step', type: :system do
|
||||
it 'shows the email step' do
|
||||
expect(page).to have_content('You have been invited to submit a form')
|
||||
expect(page).to have_content("Invited by #{account.name}")
|
||||
expect(page).to have_field('Email', type: 'email')
|
||||
@@ -813,7 +813,9 @@ RSpec.describe 'Signing Form', type: :system do
|
||||
end
|
||||
|
||||
context 'when the template requires multiple submitters' do
|
||||
let(:template) { create(:template, submitter_count: 2, account:, author:, only_field_types: %w[text]) }
|
||||
let(:template) do
|
||||
create(:template, shared_link: true, submitter_count: 2, account:, author:, only_field_types: %w[text])
|
||||
end
|
||||
|
||||
context 'when default signer details are not defined' do
|
||||
it 'shows an explanation error message if a logged-in user associated with the template account opens the link' do
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Storage Settings' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user