diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3be2f058..14699880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,4 +138,5 @@ jobs: run: | bundle exec rake db:create bundle exec rake db:migrate + bundle exec rake assets:precompile bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index 623c3db2..5e4b6895 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -67,8 +67,14 @@ RSpec/MultipleMemoizedHelpers: Rails/I18nLocaleTexts: Enabled: false +Rails/FindEach: + Enabled: false + Rails/SkipsModelValidations: Enabled: false Rails/ApplicationController: Enabled: false + +Capybara/ClickLinkOrButtonStyle: + Enabled: false diff --git a/Dockerfile b/Dockerfile index acf9dfbf..e0c2b4b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test" WORKDIR /app -RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation +RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt COPY ./Gemfile ./Gemfile.lock ./ @@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./ COPY --from=webpack /app/public/packs ./public/packs +RUN ln -s /fonts /app/public/fonts RUN bundle exec bootsnap precompile --gemfile app/ lib/ WORKDIR /data/docuseal diff --git a/Gemfile b/Gemfile index 63efe6c7..c967bd8f 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ gem 'devise-two-factor' gem 'dotenv', require: false gem 'email_typo' gem 'faraday' +gem 'faraday-follow_redirects' gem 'google-cloud-storage', require: false gem 'hexapdf' gem 'image_processing' diff --git a/Gemfile.lock b/Gemfile.lock index d07463b2..92fb8dcf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,7 +98,6 @@ GEM faraday_middleware (~> 1.0, >= 1.0.0.rc1) net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - base64 (0.1.1) bcrypt (3.1.19) better_html (2.0.2) actionview (>= 6.0) @@ -189,6 +188,8 @@ GEM faraday-em_http (1.0.0) faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) @@ -292,7 +293,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.4) - minitest (5.19.0) + minitest (5.20.0) msgpack (1.7.2) multi_json (1.15.0) multi_xml (0.6.0) @@ -343,7 +344,7 @@ GEM os (1.1.4) pagy (6.0.4) parallel (1.23.0) - parser (3.2.2.3) + parser (3.2.2.4) ast (~> 2.4.1) racc pdf-reader (2.11.0) @@ -416,7 +417,7 @@ GEM rake (13.0.6) redis-client (0.16.0) connection_pool - regexp_parser (2.8.1) + regexp_parser (2.8.2) reline (0.3.8) io-console (~> 0.5) representable (3.2.0) @@ -453,33 +454,32 @@ GEM rspec-mocks (~> 3.12) rspec-support (~> 3.12) rspec-support (3.12.1) - rubocop (1.56.1) - base64 (~> 0.1.1) + rubocop (1.57.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.28.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) + rubocop-ast (1.30.0) parser (>= 3.2.1.0) - rubocop-capybara (2.18.0) + rubocop-capybara (2.19.0) rubocop (~> 1.41) - rubocop-factory_bot (2.23.1) + rubocop-factory_bot (2.24.0) rubocop (~> 1.33) - rubocop-performance (1.19.0) + rubocop-performance (1.19.1) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.20.2) + rubocop-rails (2.22.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-rspec (2.23.2) - rubocop (~> 1.33) + rubocop-rspec (2.25.0) + rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) @@ -535,7 +535,7 @@ GEM tzinfo-data (1.2023.3) tzinfo (>= 1.0.0) uber (0.1.0) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) uniform_notifier (1.16.0) version_gem (1.1.3) warden (1.2.9) @@ -580,6 +580,7 @@ DEPENDENCIES factory_bot_rails faker faraday + faraday-follow_redirects google-cloud-storage hexapdf image_processing diff --git a/README.md b/README.md index 2eab1042..6ec713e5 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d | [Deploy on Heroku](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [Deploy on Railway](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| |**DigitalOcean**|**Render**| | [Deploy on DigitalOcean](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [Deploy to Render](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render) -|**Koyeb**| | -| [Deploy on Koyeb](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | | +|**Koyeb**|**Elestio**| +| [Deploy on Koyeb](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [Deploy on Elestio](https://dash.elest.io/deploy?soft=DocuSeal&id=339) | #### Docker @@ -81,9 +81,9 @@ HOST=your-domain-name.com docker-compose up At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws. -[![Book an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal) +[Book a Meeting](https://calendly.com/kriti-docuseal/30min) ## License Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. -Unless otherwise noted, all files © 2023 Oleksandr Turchyn. +Unless otherwise noted, all files © 2023 DocuSeal LLC. diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index 57e3d216..acaef82f 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -3,6 +3,12 @@ module Api class ApiBaseController < ActionController::API include ActiveStorage::SetCurrent + include Pagy::Backend + + DEFAULT_LIMIT = 10 + MAX_LIMIT = 100 + + wrap_parameters false before_action :authenticate_user! check_authorization @@ -17,6 +23,16 @@ module Api private + def paginate(relation) + result = relation.order(id: :desc) + .limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min) + + result = result.where('id < ?', params[:after]) if params[:after].present? + result = result.where('id > ?', params[:before]) if params[:before].present? + + result + end + def current_account current_user&.account end diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index c3000285..bff765a8 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -2,78 +2,158 @@ module Api class SubmissionsController < ApiBaseController - UnknownFieldName = Class.new(StandardError) - UnknownSubmitterName = Class.new(StandardError) + load_and_authorize_resource :template, only: :create + load_and_authorize_resource :submission, only: %i[show index] - load_and_authorize_resource :template - - before_action do + before_action only: :create do authorize!(:create, Submission) end + def index + submissions = Submissions.search(@submissions, params[:q]) + submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? + + submissions = paginate(submissions.preload(:created_by_user, :template, :submitters, + audit_trail_attachment: :blob)) + + render json: { + data: submissions.as_json(serialize_params), + pagination: { + count: submissions.size, + next: submissions.last&.id, + prev: submissions.first&.id + } + } + end + + def show + serialized_subbmitters = + @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter| + Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at? + + Submitters::SerializeForApi.call(submitter) + end + + json = @submission.as_json( + serialize_params.deep_merge( + include: { + submission_events: { + only: %i[id submitter_id event_type event_timestamp] + } + } + ) + ) + + json[:submitters] = serialized_subbmitters + + render json: + end + def create + is_send_email = !params[:send_email].in?(['false', false]) + submissions = - if (emails = (params[:emails] || params[:email]).presence) + if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank? Submissions.create_from_emails(template: @template, user: current_user, source: :api, - mark_as_sent: params[:send_email] != 'false', + mark_as_sent: is_send_email, emails:) else - submissions_attrs = normalize_submissions_params!(submissions_params[:submission], @template) + submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template) Submissions.create_from_submitters( template: @template, user: current_user, source: :api, - mark_as_sent: params[:send_email] != 'false', + mark_as_sent: is_send_email, submitters_order: params[:submitters_order] || 'preserved', submissions_attrs: ) end - Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false') + Submissions.send_signature_requests(submissions, send_email: is_send_email) + + submitters = submissions.flat_map(&:submitters) + + save_default_value_attachments!(attachments, submitters) - render json: submissions.flat_map(&:submitters) - rescue UnknownFieldName, UnknownSubmitterName => e + render json: submitters + rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e render json: { error: e.message }, status: :unprocessable_entity end + def destroy + @submission.update!(deleted_at: Time.current) + + render json: @submission.as_json(only: %i[id deleted_at]) + end + private + def serialize_params + { + only: %i[id source submitters_order created_at updated_at], + methods: %i[audit_log_url], + include: { + submitters: { only: %i[id slug uuid name email phone + completed_at opened_at sent_at + created_at updated_at], + methods: %i[status] }, + template: { only: %i[id name created_at updated_at] }, + created_by_user: { only: %i[id email first_name last_name] } + } + } + end + def submissions_params - params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }]) + params.permit(submission: [{ + submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key, + { values: {}, readonly_fields: [], + fields: [%i[name default_value readonly validation_pattern invalid_message]] }]] + }]) end def normalize_submissions_params!(submissions_params, template) - submissions_params.each do |submission| + attachments = [] + + Array.wrap(submissions_params).each do |submission| submission[:submitters].each_with_index do |submitter, index| - next if submitter[:values].blank? + default_values = submitter[:values] || {} - submitter[:values] = - normalize_submitter_values(template, - submitter[:values], - submitter[:role] || template.submitters[index]['name']) + submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? } + + next if default_values.blank? + + values, new_attachments = + Submitters::NormalizeValues.call(template, + default_values, + submitter[:role] || template.submitters[index]['name']) + + attachments.push(*new_attachments) + + submitter[:values] = values end end - submissions_params + [submissions_params, attachments] end - def normalize_submitter_values(template, values, submitter_name) - submitter = - template.submitters.find { |e| e['name'] == submitter_name } || - raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}") + def save_default_value_attachments!(attachments, submitters) + return if attachments.blank? + + attachments_index = attachments.index_by(&:uuid) - fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] } + submitters.each do |submitter| + submitter.values.to_a.each do |_, value| + attachment = attachments_index[value] - fields_uuid_index = fields.index_by { |e| e['uuid'] } - fields_name_index = fields.index_by { |e| e['name'] } + next unless attachment - values.transform_keys do |key| - next key if fields_uuid_index[key].present? + attachment.record = submitter - fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}") + attachment.save! + end end end end diff --git a/app/controllers/api/submitters_autocomplete_controller.rb b/app/controllers/api/submitters_autocomplete_controller.rb new file mode 100644 index 00000000..ff7dc2e6 --- /dev/null +++ b/app/controllers/api/submitters_autocomplete_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Api + class SubmittersAutocompleteController < ApiBaseController + load_and_authorize_resource :submitter, parent: false + + SELECT_COLUMNS = %w[email phone name].freeze + LIMIT = 100 + + def index + submitters = search_submitters(@submitters) + + values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', ')) + + attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h } + attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present? + + render json: attrs + end + + private + + def search_submitters(submitters) + if SELECT_COLUMNS.include?(params[:field]) + column = Submitter.arel_table[params[:field].to_sym] + + term = "%#{params[:q].downcase}%" + + submitters.where(column.lower.matches(term)) + else + Submitters.search(submitters, params[:q]) + end + end + end +end diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb new file mode 100644 index 00000000..a0abb9c8 --- /dev/null +++ b/app/controllers/api/submitters_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Api + class SubmittersController < ApiBaseController + load_and_authorize_resource :submitter + + def index + submitters = Submitters.search(@submitters, params[:q]) + + submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present? + submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? + + submitters = paginate( + submitters.preload(:template, :submission, :submission_events, + documents_attachments: :blob, attachments_attachments: :blob) + ) + + render json: { + data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) }, + pagination: { + count: submitters.size, + next: submitters.last&.id, + prev: submitters.first&.id + } + } + end + + def show + Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at? + + render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true) + end + end +end diff --git a/app/controllers/api/template_folders_autocomplete_controller.rb b/app/controllers/api/template_folders_autocomplete_controller.rb new file mode 100644 index 00000000..19365e76 --- /dev/null +++ b/app/controllers/api/template_folders_autocomplete_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + class TemplateFoldersAutocompleteController < ApiBaseController + load_and_authorize_resource :template_folder, parent: false + + LIMIT = 100 + + def index + template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct + template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT) + + render json: template_folders.as_json(only: %i[name deleted_at]) + end + end +end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index ef4a1e33..8bf8a1a2 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -5,28 +5,60 @@ module Api load_and_authorize_resource :template def index - render json: @templates + templates = Templates.search(@templates, params[:q]) + + templates = params[:archived] ? templates.archived : templates.active + templates = templates.where(application_key: params[:application_key]) if params[:application_key].present? + + templates = paginate(templates.preload(:author, documents_attachments: :blob)) + + render json: { + data: templates.as_json(serialize_params), + pagination: { + count: templates.size, + next: templates.last&.id, + prev: templates.first&.id + } + } end def show - render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] }, - documents: { only: %i[id uuid], methods: %i[url filename] } }) + render json: @template.as_json(serialize_params) end def update + if (folder_name = params.dig(:template, :folder_name)) + @template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name) + end + @template.update!(template_params) - render :ok + render json: @template.as_json(only: %i[id updated_at]) + end + + def destroy + @template.update!(deleted_at: Time.current) + + render json: @template.as_json(only: %i[id deleted_at]) end private + def serialize_params + { + include: { author: { only: %i[id email first_name last_name] }, + documents: { only: %i[id uuid], methods: %i[url filename] } } + } + end + def template_params - params.require(:template).permit(:name, - schema: [%i[attachment_uuid name]], - submitters: [%i[name uuid]], - fields: [[:uuid, :submitter_uuid, :name, :type, :required, - { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]) + params.require(:template).permit( + :name, + schema: [%i[attachment_uuid name]], + submitters: [%i[name uuid]], + fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, + { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]] + ) end end end diff --git a/app/controllers/api/templates_documents_controller.rb b/app/controllers/api/templates_documents_controller.rb index 1657e70e..04fdb12c 100644 --- a/app/controllers/api/templates_documents_controller.rb +++ b/app/controllers/api/templates_documents_controller.rb @@ -16,6 +16,7 @@ module Api render json: { schema:, documents: documents.as_json( + methods: [:metadata], include: { preview_images: { methods: %i[url metadata filename] } } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e860fe4f..38b541aa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -33,7 +33,7 @@ class ApplicationController < ActionController::Base private def sign_in_for_demo - sign_in(User.order('random()').take) unless signed_in? + sign_in(User.active.order('random()').take) unless signed_in? end def current_account diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index fe7c1c5b..aa08ae4d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -6,17 +6,56 @@ class DashboardController < ApplicationController before_action :maybe_redirect_product_url before_action :maybe_render_landing + load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template, parent: false + SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9 + TEMPLATES_PER_PAGE = 12 + FOLDERS_PER_PAGE = 18 + def index - @templates = @templates.active.preload(:author).order(id: :desc) - @templates = Templates.search(@templates, params[:q]) + @template_folders = filter_template_folders(@template_folders) + + @pagy, @template_folders = pagy( + @template_folders, + items: FOLDERS_PER_PAGE, + page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 + ) + + if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD + @templates = @templates.none + else + @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } + @templates = filter_templates(@templates) - @pagy, @templates = pagy(@templates, items: 12) + items = + if @template_folders.size < 4 + TEMPLATES_PER_PAGE + else + (@template_folders.size < 7 ? 9 : 6) + end + + @pagy, @templates = pagy(@templates, items:) + end end private + def filter_template_folders(template_folders) + rel = template_folders.joins(:active_templates) + .order(id: :desc) + .distinct + + TemplateFolders.search(rel, params[:q]) + end + + def filter_templates(templates) + rel = templates.active.preload(:author).order(id: :desc) + rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank? + + Templates.search(rel, params[:q]) + end + def maybe_redirect_product_url return if !Docuseal.multitenant? || signed_in? diff --git a/app/controllers/enquiries_controller.rb b/app/controllers/enquiries_controller.rb new file mode 100644 index 00000000..829b578c --- /dev/null +++ b/app/controllers/enquiries_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class EnquiriesController < ApplicationController + skip_before_action :authenticate_user! + skip_authorization_check + + def create + if params[:talk_to_sales] == 'on' + Faraday.post(Docuseal::ENQUIRIES_URL, + enquiry_params.merge(type: :talk_to_sales).to_json, + 'Content-Type' => 'application/json') + end + + head :ok + end + + private + + def enquiry_params + params.require(:user).permit(:email) + end +end diff --git a/app/controllers/mfa_setup_controller.rb b/app/controllers/mfa_setup_controller.rb index 32cea983..ce690643 100644 --- a/app/controllers/mfa_setup_controller.rb +++ b/app/controllers/mfa_setup_controller.rb @@ -10,7 +10,7 @@ class MfaSetupController < ApplicationController current_user.save! - @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME) + @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) end def edit; end @@ -22,7 +22,7 @@ class MfaSetupController < ApplicationController redirect_to settings_profile_index_path, notice: '2FA has been configured' else - @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME) + @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) @error_message = 'Code is invalid' diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 00000000..da67abc6 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PasswordsController < Devise::PasswordsController + class Current < ActiveSupport::CurrentAttributes + attribute :user + end + + def update + super do |resource| + Current.user = resource + end + end +end diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb new file mode 100644 index 00000000..8c51507e --- /dev/null +++ b/app/controllers/preview_document_page_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class PreviewDocumentPageController < ActionController::API + include ActiveStorage::SetCurrent + + FORMAT = Templates::ProcessDocument::FORMAT + + def show + attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid]) + + return head :not_found unless attachment + + preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" }) + + return redirect_to preview_image.url, allow_other_host: true if preview_image + + file_path = + if attachment.service.name == :disk + ActiveStorage::Blob.service.path_for(attachment.key) + else + find_or_create_document_tempfile_path(attachment) + end + + io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) + + render plain: io.tap(&:rewind).read + end + + def find_or_create_document_tempfile_path(attachment) + file_path = "#{Dir.tmpdir}/#{attachment.uuid}" + + File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| + f.flock(File::LOCK_EX) + + # rubocop:disable Style/ZeroLengthPredicate + if f.size.zero? + f.binmode + + f.write(attachment.download) + end + # rubocop:enable Style/ZeroLengthPredicate + end + + file_path + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index bca21d22..d0b4a403 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController redirect_to after_sign_up_path_for(current_user), allow_other_host: true end - def require_no_authentication - super + def set_flash_message(key, kind, options = {}) + return if key == :alert && kind == 'already_authenticated' - flash.clear + super end def build_resource(_hash = {}) @@ -43,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController self.resource = account.users.new(user_params) - account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create' + account.name ||= resource.full_name if params[:action] == 'create' end def user_params diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4cfa542e..96379fd9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) end - def require_no_authentication - super + def set_flash_message(key, kind, options = {}) + return if key == :alert && kind == 'already_authenticated' - flash.clear + super end end diff --git a/app/controllers/sso_settings_controller.rb b/app/controllers/sso_settings_controller.rb new file mode 100644 index 00000000..9e90d1de --- /dev/null +++ b/app/controllers/sso_settings_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class SsoSettingsController < ApplicationController + before_action :load_encrypted_config + authorize_resource :encrypted_config, only: :index + authorize_resource :encrypted_config, parent: false, except: :index + + def index; end + + private + + def load_encrypted_config + @encrypted_config = + EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs') + end +end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 8e8d27d6..ca054121 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -14,14 +14,14 @@ class StartFormController < ApplicationController def update @submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil)) - .find_or_initialize_by(email: submitter_params[:email]) + .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } + .find_or_initialize_by(**submitter_params.compact_blank) if @submitter.completed_at? redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) else @submitter.assign_attributes( uuid: @template.submitters.first['uuid'], - opened_at: Time.current, ip: request.remote_ip, ua: request.user_agent ) @@ -47,7 +47,7 @@ class StartFormController < ApplicationController private def submitter_params - params.require(:submitter).permit(:email).tap do |attrs| + params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| attrs[:email] = Submissions.normalize_email(attrs[:email]) end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 311efb19..a327d56e 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController load_and_authorize_resource :submission, only: %i[show destroy] + PRELOAD_ALL_PAGES_AMOUNT = 200 + def show ActiveRecord::Associations::Preloader.new( records: [@submission], - associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }] + associations: [:template, { template_schema_documents: :blob }] ).call + total_pages = + @submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } + + if total_pages < PRELOAD_ALL_PAGES_AMOUNT + ActiveRecord::Associations::Preloader.new( + records: @submission.template_schema_documents, + associations: [:blob, { preview_images_attachments: :blob }] + ).call + end + render :show, layout: 'plain' end diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index cf27e8f1..01fa5adc 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -6,16 +6,33 @@ class SubmitFormController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check + PRELOAD_ALL_PAGES_AMOUNT = 200 + def show - @submitter = - Submitter.preload(submission: [ - :template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] } - ]) - .find_by!(slug: params[:slug]) + @submitter = Submitter.find_by!(slug: params[:slug]) return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? + ActiveRecord::Associations::Preloader.new( + records: [@submitter], + associations: [submission: [:template, { template_schema_documents: :blob }]] + ).call + + total_pages = + @submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } + + if total_pages < PRELOAD_ALL_PAGES_AMOUNT + ActiveRecord::Associations::Preloader.new( + records: @submitter.submission.template_schema_documents, + associations: [:blob, { preview_images_attachments: :blob }] + ).call + end + + Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) + cookies[:submitter_sid] = @submitter.signed_id + + render @submitter.submission.template.deleted_at? ? :archived : :show end def update diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb new file mode 100644 index 00000000..80b81632 --- /dev/null +++ b/app/controllers/template_folders_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class TemplateFoldersController < ApplicationController + load_and_authorize_resource :template_folder + + def show + @templates = @template_folder.templates.active.preload(:author).order(id: :desc) + @templates = Templates.search(@templates, params[:q]) + + @pagy, @templates = pagy(@templates, items: 12) + end + + def edit; end + + def update + if @template_folder != current_account.default_template_folder && + @template_folder.update(template_folder_params) + redirect_to folder_path(@template_folder), notice: 'Folder name has been updated' + else + redirect_to folder_path(@template_folder), alert: 'Unable to rename folder' + end + end + + private + + def template_folder_params + params.require(:template_folder).permit(:name) + end +end diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index f598b817..82f286d0 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController load_and_authorize_resource :template, parent: false def index - @templates = @templates.where.not(deleted_at: nil).preload(:author).order(id: :desc) + @templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc) @templates = Templates.search(@templates, params[:q]) @pagy, @templates = pagy(@templates, items: 12) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 5d9a7e27..971a6ba2 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -10,6 +10,11 @@ class TemplatesController < ApplicationController submissions = submissions.active if @template.deleted_at.blank? submissions = Submissions.search(submissions, params[:q]) + @base_submissions = submissions + + submissions = submissions.pending if params[:status] == 'pending' + submissions = submissions.completed if params[:status] == 'completed' + @pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc)) rescue ActiveRecord::RecordNotFound redirect_to root_path @@ -25,12 +30,21 @@ class TemplatesController < ApplicationController associations: [schema_documents: { preview_images_attachments: :blob }] ).call + @template_data = + @template.as_json.merge( + documents: @template.schema_documents.as_json( + methods: [:metadata], + include: { preview_images: { methods: %i[url metadata filename] } } + ) + ).to_json + render :edit, layout: 'plain' end def create @template.account = current_account @template.author = current_user + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template if @template.save @@ -43,9 +57,18 @@ class TemplatesController < ApplicationController end def destroy - @template.update!(deleted_at: Time.current) + notice = + if !Docuseal.multitenant? && params[:permanently].present? + @template.destroy! + + 'Template has been removed.' + else + @template.update!(deleted_at: Time.current) + + 'Template has been archived.' + end - redirect_back(fallback_location: root_path, notice: 'Template has been archived.') + redirect_back(fallback_location: root_path, notice:) end private diff --git a/app/controllers/templates_folders_controller.rb b/app/controllers/templates_folders_controller.rb new file mode 100644 index 00000000..70b6e341 --- /dev/null +++ b/app/controllers/templates_folders_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TemplatesFoldersController < ApplicationController + load_and_authorize_resource :template + + def edit; end + + def update + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) + + if @template.save + redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved') + else + redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder') + end + end + + private + + def template_folder_params + params.require(:template_folder).permit(:name) + end +end diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index fd57f577..7a41c1c2 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -3,18 +3,55 @@ class TemplatesUploadsController < ApplicationController load_and_authorize_resource :template, parent: false + layout 'plain' + + def show; end + def create + url_params = create_file_params_from_url if params[:url].present? + + @template.account = current_account @template.author = current_user - @template.name = File.basename(params[:files].first.original_filename, '.*') + @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) + @template.name = File.basename((url_params || params)[:files].first.original_filename, '.*') @template.save! - documents = Templates::CreateAttachments.call(@template, params) + documents = Templates::CreateAttachments.call(@template, url_params || params) schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } @template.update!(schema:) redirect_to edit_template_path(@template) + rescue StandardError => e + Rollbar.error(e) if defined?(Rollbar) + + redirect_to root_path, alert: 'Unable to upload file' + end + + private + + def create_file_params_from_url + tempfile = Tempfile.new + tempfile.binmode + tempfile.write(conn.get(params[:url]).body) + tempfile.rewind + + file = ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename: File.basename( + URI.decode_www_form_component(params[:filename].presence || params[:url]) + ), + type: Marcel::MimeType.for(tempfile) + ) + + { files: [file] } + end + + def conn + Faraday.new do |faraday| + faraday.response :follow_redirects + end end end diff --git a/app/controllers/user_signatures_controller.rb b/app/controllers/user_signatures_controller.rb new file mode 100644 index 00000000..69d0e795 --- /dev/null +++ b/app/controllers/user_signatures_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class UserSignaturesController < ApplicationController + before_action :load_user_config + authorize_resource :user_config + + def edit; end + + def update + file = params[:file] + + return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank? + + blob = ActiveStorage::Blob.create_and_upload!(io: file.open, + filename: file.original_filename, + content_type: file.content_type) + + attachment = ActiveStorage::Attachment.create!( + blob:, + name: 'signature', + record: current_user + ) + + if @user_config.update(value: attachment.uuid) + redirect_to settings_profile_index_path, notice: 'Signature has been saved' + else + redirect_to settings_profile_index_path, notice: 'Unable to save signature' + end + end + + private + + def load_user_config + @user_config = + UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7cb672ce..c84efe0c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -27,7 +27,10 @@ class UsersController < ApplicationController def update return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo? - if @user.update(user_params.compact_blank.except(current_user == @user ? :role : nil)) + attrs = user_params.compact_blank + attrs.delete(:role) if User::ROLES.exclude?(attrs[:role]) + + if @user.update(attrs.except(current_user == @user ? :role : nil)) redirect_to settings_users_path, notice: 'User has been updated' else render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity diff --git a/app/controllers/verify_pdf_signature_controller.rb b/app/controllers/verify_pdf_signature_controller.rb index 49d9e44a..26bcdcc5 100644 --- a/app/controllers/verify_pdf_signature_controller.rb +++ b/app/controllers/verify_pdf_signature_controller.rb @@ -12,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController cert_data = if Docuseal.multitenant? Docuseal::CERTS else - EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {} + EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {} end default_pkcs = GenerateCertificate.load_pkcs(cert_data) diff --git a/app/javascript/application.js b/app/javascript/application.js index 8860eb7f..da94ac1f 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -15,6 +15,10 @@ import DownloadButton from './elements/download_button' import SetOriginUrl from './elements/set_origin_url' import SetTimezone from './elements/set_timezone' import AutoresizeTextarea from './elements/autoresize_textarea' +import SubmittersAutocomplete from './elements/submitter_autocomplete' +import FolderAutocomplete from './elements/folder_autocomplete' +import SignatureForm from './elements/signature_form' +import SubmitForm from './elements/submit_form' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -43,6 +47,10 @@ window.customElements.define('download-button', DownloadButton) window.customElements.define('set-origin-url', SetOriginUrl) window.customElements.define('set-timezone', SetTimezone) window.customElements.define('autoresize-textarea', AutoresizeTextarea) +window.customElements.define('submitters-autocomplete', SubmittersAutocomplete) +window.customElements.define('folder-autocomplete', FolderAutocomplete) +window.customElements.define('signature-form', SignatureForm) +window.customElements.define('submit-form', SubmitForm) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:submit-end', async (event) => { @@ -70,12 +78,12 @@ document.addEventListener('turbo:submit-end', async (event) => { window.customElements.define('template-builder', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') - this.appElem.classList.add('max-h-screen') this.app = createApp(TemplateBuilder, { template: reactive(JSON.parse(this.dataset.template)), backgroundColor: '#faf7f5', withPhone: this.dataset.withPhone === 'true', + withLogo: this.dataset.withLogo !== 'false', acceptFileTypes: this.dataset.acceptFileTypes, isDirectUpload: this.dataset.isDirectUpload === 'true' }) diff --git a/app/javascript/application.scss b/app/javascript/application.scss index ea130f21..672e1b23 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -74,6 +74,58 @@ button[disabled] .enabled { .base-select { @apply select base-input w-full font-normal; } + .bg-redact{ background: black; -} \ No newline at end of file +} + +.tooltip-bottom-end:before { + transform: translateX(-95%); + top: var(--tooltip-offset); + left: 100%; + right: auto; + bottom: auto; +} + +.tooltip-bottom-end:after { + transform: translateX(-25%); + border-color: transparent transparent var(--tooltip-color) transparent; + top: var(--tooltip-tail-offset); + left: 50%; + right: auto; + bottom: auto; +} + +.autocomplete { + background: white; + z-index: 1000; + font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + overflow: auto; + box-sizing: border-box; + @apply border border-base-300 mt-1 rounded-md; +} + +.autocomplete * { + font: inherit; +} + +.autocomplete > div { + @apply px-2 py-1 font-normal text-sm; +} + +.autocomplete .group { + background: #eee; +} + +.autocomplete > div:hover:not(.group), +.autocomplete > div.selected { + @apply bg-base-300; + cursor: pointer; +} + +.input-outlined { + outline-style: solid; + outline-width: 1px; + outline-offset: 3px; + outline-color: hsl(var(--bc) / 0.2); +} diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index 1bf0c8bd..7511da06 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement { this.append(input) }) - if (this.dataset.submitOnUpload) { + if (this.dataset.submitOnUpload === 'true') { this.closest('form').querySelector('button[type="submit"]').click() } }).finally(() => { - this.toggleLoading() + if (this.dataset.submitOnUpload !== 'true') { + this.toggleLoading() + } }) } else { if (this.dataset.submitOnUpload) { diff --git a/app/javascript/elements/folder_autocomplete.js b/app/javascript/elements/folder_autocomplete.js new file mode 100644 index 00000000..3d0bb4e3 --- /dev/null +++ b/app/javascript/elements/folder_autocomplete.js @@ -0,0 +1,43 @@ +import autocomplete from 'autocompleter' + +export default class extends HTMLElement { + connectedCallback () { + autocomplete({ + input: this.input, + preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1, + minLength: 0, + showOnFocus: true, + onSelect: this.onSelect, + render: this.render, + fetch: this.fetch + }) + } + + onSelect = (item) => { + this.input.value = item.name + } + + fetch = (text, resolve) => { + const queryParams = new URLSearchParams({ q: text }) + + fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => { + const items = await resp.json() + + resolve(items) + }).catch(() => { + resolve([]) + }) + } + + render = (item) => { + const div = document.createElement('div') + + div.textContent = item.name + + return div + } + + get input () { + return this.querySelector('input') + } +} diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js new file mode 100644 index 00000000..535fc8a6 --- /dev/null +++ b/app/javascript/elements/signature_form.js @@ -0,0 +1,46 @@ +import { target, targetable } from '@github/catalyst/lib/targetable' +import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas' + +export default targetable(class extends HTMLElement { + static [target.static] = ['canvas', 'input', 'clear', 'button'] + + async connectedCallback () { + this.canvas.width = this.canvas.parentNode.parentNode.clientWidth + this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3 + + const { default: SignaturePad } = await import('signature_pad') + + this.pad = new SignaturePad(this.canvas) + + this.clear.addEventListener('click', (e) => { + e.preventDefault() + + this.pad.clear() + }) + + this.button.addEventListener('click', (e) => { + e.preventDefault() + + this.button.disabled = true + + this.submit() + }) + } + + async submit () { + const blob = await cropCanvasAndExportToPNG(this.canvas) + const file = new File([blob], 'signature.png', { type: 'image/png' }) + + const dataTransfer = new DataTransfer() + + dataTransfer.items.add(file) + + this.input.files = dataTransfer.files + + if (this.input.webkitEntries.length) { + this.input.dataset.file = `${dataTransfer.files[0].name}` + } + + this.closest('form').requestSubmit() + } +}) diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js new file mode 100644 index 00000000..4b5969e5 --- /dev/null +++ b/app/javascript/elements/submit_form.js @@ -0,0 +1,5 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelector('form').requestSubmit() + } +} diff --git a/app/javascript/elements/submitter_autocomplete.js b/app/javascript/elements/submitter_autocomplete.js new file mode 100644 index 00000000..0e5f8a8f --- /dev/null +++ b/app/javascript/elements/submitter_autocomplete.js @@ -0,0 +1,67 @@ +import autocomplete from 'autocompleter' + +export default class extends HTMLElement { + connectedCallback () { + autocomplete({ + input: this.input, + preventSubmit: 1, + minLength: 1, + showOnFocus: true, + onSelect: this.onSelect, + render: this.render, + fetch: this.fetch + }) + } + + onSelect = (item) => { + const fields = ['email', 'name', 'phone'] + const submitterItemEl = this.closest('submitter-item') + + fields.forEach((field) => { + const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`) + const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`) + + if (input && item[field]) { + input.value = item[field] + } + + if (textarea && item[field]) { + textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') + } + }) + } + + fetch = (text, resolve) => { + const q = text.split(/[;,\s]+/).pop().trim() + + if (q) { + const queryParams = new URLSearchParams({ q, field: this.dataset.field }) + + fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => { + const items = await resp.json() + + if (q.length < 3) { + resolve(items.filter((e) => e[this.dataset.field].startsWith(q))) + } else { + resolve(items) + } + }).catch(() => { + resolve([]) + }) + } else { + resolve([]) + } + } + + render = (item) => { + const div = document.createElement('div') + + div.textContent = item[this.dataset.field] + + return div + } + + get input () { + return this.querySelector('input') || this.querySelector('textarea') + } +} diff --git a/app/javascript/form.js b/app/javascript/form.js index df1eff3d..ebdcea17 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement { authenticityToken: this.dataset.authenticityToken, canSendEmail: this.dataset.canSendEmail === 'true', isDirectUpload: this.dataset.isDirectUpload === 'true', + goToLast: this.dataset.goToLast === 'true', isDemo: this.dataset.isDemo === 'true', attribution: this.dataset.attribution !== 'false', withConfetti: true, values: reactive(JSON.parse(this.dataset.values)), + completedButton: JSON.parse(this.dataset.completedButton), attachments: reactive(JSON.parse(this.dataset.attachments)), fields: JSON.parse(this.dataset.fields) }) diff --git a/app/javascript/form.scss b/app/javascript/form.scss index a9bac997..c6686829 100644 --- a/app/javascript/form.scss +++ b/app/javascript/form.scss @@ -47,6 +47,10 @@ select:required:invalid { @apply border-base-content/20; } +.base-textarea { + @apply textarea textarea-bordered bg-white rounded-3xl; +} + .btn { @apply no-animation; } diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 263f7409..91327d74 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -86,6 +86,11 @@ class="object-contain mx-auto" :src="signature.url" > +
{{ formattedDate }} - - {{ modelValue }} - + {{ modelValue }}
diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 0f31e497..fc24acb2 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -58,54 +58,20 @@ >
- -
-
- -
-
-
- -
-
- -
+
+
+
+ {{ t('please_fill_all_required_fields') }} +
@@ -337,10 +335,13 @@ import FieldAreas from './areas' import ImageStep from './image_step' import SignatureStep from './signature_step' +import InitialsStep from './initials_step' import AttachmentStep from './attachment_step' import MultiSelectStep from './multi_select_step' import PhoneStep from './phone_step' import RedactStep from './redact_step.vue' +import TextStep from './text_step' +import DateStep from './date_step' import FormCompleted from './completed' import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { t } from './i18n' @@ -352,9 +353,12 @@ export default { ImageStep, SignatureStep, AttachmentStep, + InitialsStep, MultiSelectStep, IconInnerShadowTop, + DateStep, IconArrowsDiagonal, + TextStep, PhoneStep, RedactStep, IconArrowsDiagonalMinimize2, @@ -381,6 +385,13 @@ export default { required: false, default: () => [] }, + onComplete: { + type: Function, + required: false, + default () { + return () => {} + } + }, withConfetti: { type: Boolean, required: false, @@ -411,6 +422,16 @@ export default { required: false, default: false }, + allowToSkip: { + type: Boolean, + required: false, + default: false + }, + goToLast: { + type: Boolean, + required: false, + default: true + }, isDemo: { type: Boolean, required: false, @@ -425,14 +446,21 @@ export default { type: Object, required: false, default: () => ({}) + }, + completedButton: { + type: Object, + required: false, + default: () => ({}) } }, data () { return { isCompleted: false, isFormVisible: true, + showFillAllRequiredFields: false, currentStep: 0, isSubmitting: false, + submittedValues: {}, recalculateButtonDisabledKey: '' } }, @@ -443,6 +471,16 @@ export default { submitterSlug () { return this.submitter.slug }, + previousSignatureValue () { + const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid]) + + return this.values[signatureField?.uuid] + }, + previousInitialsValue () { + const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid]) + + return this.values[initialsField?.uuid] + }, isAnonymousChecboxes () { return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4 }, @@ -450,7 +488,8 @@ export default { if (this.recalculateButtonDisabledKey) { return this.isSubmitting || (this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) || - (this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) + (this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) || + (this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted) } else { return false } @@ -459,7 +498,7 @@ export default { return this.currentStepFields[0] }, stepFields () { - return this.fields.reduce((acc, f) => { + return this.fields.filter((f) => !f.readonly).reduce((acc, f) => { const prevStep = acc[acc.length - 1] if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') { @@ -483,10 +522,30 @@ export default { } }, mounted () { - this.currentStep = Math.min( - this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1, - this.stepFields.length - 1 - ) + this.submittedValues = JSON.parse(JSON.stringify(this.values)) + + this.fields.forEach((field) => { + if (field.default_value && !field.readonly) { + this.values[field.uuid] = field.default_value + } + }) + + if (this.goToLast) { + const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid]))) + const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1 + + const indexesList = [this.stepFields.length - 1] + + if (requiredEmptyStepIndex !== -1) { + indexesList.push(requiredEmptyStepIndex) + } + + if (lastFilledStepIndex !== -1) { + indexesList.push(lastFilledStepIndex) + } + + this.currentStep = Math.min(...indexesList) + } if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { this.$nextTick(() => { @@ -505,7 +564,10 @@ export default { this.$nextTick(() => { this.recalculateButtonDisabledKey = Math.random() - this.maybeTrackEmailClick().finally(() => { + Promise.all([ + this.maybeTrackEmailClick(), + this.maybeTrackSmsClick() + ]).finally(() => { this.trackViewForm() }) }) @@ -536,6 +598,30 @@ export default { return Promise.resolve({}) } }, + maybeTrackSmsClick () { + const queryParams = new URLSearchParams(window.location.search) + + if (queryParams.has('c')) { + const c = queryParams.get('c') + + queryParams.delete('c') + const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') + window.history.replaceState({}, document.title, newUrl) + + return fetch(this.baseUrl + '/api/submitter_sms_clicks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + c, + submitter_slug: this.submitterSlug + }) + }) + } else { + return Promise.resolve({}) + } + }, trackViewForm () { fetch(this.baseUrl + '/api/submitter_form_views', { method: 'POST', @@ -549,6 +635,7 @@ export default { }, goToStep (step, scrollToArea = false, clickUpload = false) { this.currentStep = this.stepFields.indexOf(step) + this.showFillAllRequiredFields = false this.$nextTick(() => { this.recalculateButtonDisabledKey = Math.random() @@ -577,14 +664,19 @@ export default { async submitStep () { this.isSubmitting = true - const stepPromise = ['signature', 'phone'].includes(this.currentField.type) + const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type) ? this.$refs.currentStep.submit : () => Promise.resolve({}) stepPromise().then(async () => { + const emptyRequiredField = this.stepFields.find((fields, index) => { + return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid] + }) + const formData = new FormData(this.$refs.form) + const isLastStep = this.currentStep === this.stepFields.length - 1 - if (this.currentStep === this.stepFields.length - 1) { + if (isLastStep && !emptyRequiredField) { formData.append('completed', 'true') } @@ -597,12 +689,28 @@ export default { return Promise.reject(new Error(data.error)) } - const nextStep = this.stepFields[this.currentStep + 1] + this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid] + + if (isLastStep) { + this.isSecondWalkthrough = true + } + + const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1] if (nextStep) { - this.goToStep(this.stepFields[this.currentStep + 1], true) + this.goToStep(nextStep, true) + + if (emptyRequiredField === nextStep) { + this.showFillAllRequiredFields = true + } } else { this.isCompleted = true + + const respData = await response.text() + + if (respData) { + this.onComplete(JSON.parse(respData)) + } } }).catch(error => { console.error('Error submitting form:', error) diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index fd8c5b3c..c45b5aa3 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -10,9 +10,17 @@ const en = { or_drag_and_drop_files: 'or drag and drop files', send_copy_via_email: 'Send copy via email', download: 'Download', + signature: 'Signature', + initials: 'Initials', + clear: 'Clear', + redraw: 'Redraw', + draw_initials: 'Draw initials', + type_signature_here: 'Type signature here', + type_initial_here: 'Type initials here', form_has_been_completed: 'Form has been completed!', create_a_free_account: 'Create a Free Account', signed_with: 'Signed with', + please_check_the_box_to_continue: 'Please check the box to continue', open_source_documents_software: 'open source documents software', verified_phone_number: 'Verify Phone Number', redact: 'redact', @@ -22,6 +30,10 @@ const en = { sending: 'Sending...', resend_code: 'Re-send code', verification_code_has_been_resent: 'Verification code has been re-sent via SMS', + please_fill_all_required_fields: 'Please fill all required fields', + set_today: 'Set Today', + toggle_multiline_text: 'Toggle Multiline Text', + date: 'Date', email_has_been_sent: 'Email has been sent' } @@ -37,9 +49,17 @@ const es = { or_drag_and_drop_files: 'o arrastra y suelta archivos', send_copy_via_email: 'Enviar copia por correo electrónico', download: 'Descargar', + signature: 'Firma', + initials: 'Iniciales', + clear: 'Borrar', + redraw: 'Redibujar', + draw_initials: 'Dibujar iniciales', + type_signature_here: 'Escribe la firma aquí', + type_initial_here: 'Escribe las iniciales aquí', form_has_been_completed: '¡El formulario ha sido completado!', create_a_free_account: 'Crear una Cuenta Gratuita', signed_with: 'Firmado con', + please_check_the_box_to_continue: 'Por favor marque la casilla para continuar', open_source_documents_software: 'software de documentos de código abierto', verified_phone_number: 'Verificar número de teléfono', redact: 'redact', @@ -49,6 +69,10 @@ const es = { sending: 'Enviando...', resend_code: 'Reenviar código', verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS', + please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios', + set_today: 'Establecer Hoy', + date: 'Fecha', + toggle_multiline_text: 'Alternar Texto Multilínea', email_has_been_sent: 'El correo electrónico ha sido enviado' } @@ -64,9 +88,17 @@ const it = { or_drag_and_drop_files: 'oppure trascina e rilascia i file', send_copy_via_email: 'Invia copia via email', download: 'Scarica', + signature: 'Firma', + initials: 'Iniziali', + clear: 'Cancella', + redraw: 'Ridisegna', + draw_initials: 'Disegna iniziali', + type_signature_here: 'Scrivi la firma qui', + type_initial_here: 'Scrivi le iniziali qui', form_has_been_completed: 'Il modulo è stato completato!', create_a_free_account: 'Crea un Account Gratuito', signed_with: 'Firmato con', + please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare', open_source_documents_software: 'software di documenti open source', verified_phone_number: 'Verifica numero di telefono', redact: 'redact', @@ -76,6 +108,10 @@ const it = { sending: 'Invio in corso...', resend_code: 'Rinvia codice', verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS', + please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori', + set_today: 'Imposta Oggi', + date: 'Data', + toggle_multiline_text: 'Attiva Testo Multilinea', email_has_been_sent: "L'email è stata inviata" } @@ -91,9 +127,17 @@ const de = { or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen', send_copy_via_email: 'Kopie per E-Mail senden', download: 'Herunterladen', + signature: 'Unterschrift', + initials: 'Initialen', + clear: 'Löschen', + redraw: 'Neu zeichnen', + draw_initials: 'Initialen zeichnen', + type_signature_here: 'Unterschrift hier eingeben', + type_initial_here: 'Initialen hier eingeben', form_has_been_completed: 'Formular wurde ausgefüllt!', create_a_free_account: 'Kostenloses Konto erstellen', signed_with: 'Unterschrieben mit', + please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren', open_source_documents_software: 'Open-Source-Dokumentensoftware', verified_phone_number: 'Telefonnummer überprüfen', redact: 'redact', @@ -103,6 +147,10 @@ const de = { sending: 'Senden...', resend_code: 'Code erneut senden', verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet', + please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus', + set_today: 'Heute einstellen', + date: 'Datum', + toggle_multiline_text: 'Mehrzeiligen Text umschalten', email_has_been_sent: 'Die E-Mail wurde gesendet' } @@ -118,9 +166,17 @@ const fr = { or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers', send_copy_via_email: 'Envoyer une copie par e-mail', download: 'Télécharger', + signature: 'Signature', + initials: 'Initiales', + clear: 'Effacer', + redraw: 'Redessiner', + draw_initials: 'Dessiner les initiales', + type_signature_here: 'Tapez la signature ici', + type_initial_here: 'Tapez les initiales ici', form_has_been_completed: 'Le formulaire a été complété !', create_a_free_account: 'Créer un Compte Gratuit', signed_with: 'Signé avec', + please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer', open_source_documents_software: 'logiciel de documents open source', verified_phone_number: 'Vérifier le numéro de téléphone', redact: 'redact', @@ -130,6 +186,10 @@ const fr = { sending: 'Envoi en cours...', resend_code: 'Renvoyer le code', verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS', + please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires', + set_today: "Définir Aujourd'hui", + date: 'Date', + toggle_multiline_text: 'Basculer le Texte Multiligne', email_has_been_sent: "L'email a été envoyé" } @@ -145,9 +205,17 @@ const pl = { or_drag_and_drop_files: 'lub przeciągnij i upuść pliki', send_copy_via_email: 'Wyślij kopię drogą mailową', download: 'Pobierz', + signature: 'Podpis', + initials: 'Inicjały', + clear: 'Wyczyść', + redraw: 'Przerysuj', + draw_initials: 'Narysuj inicjały', + type_signature_here: 'Wpisz podpis tutaj', + type_initial_here: 'Wpisz inicjały tutaj', form_has_been_completed: 'Formularz został wypełniony!', create_a_free_account: 'Utwórz darmowe konto', signed_with: 'Podpisane za pomocą', + please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować', open_source_documents_software: 'oprogramowanie do dokumentów open source', verified_phone_number: 'Zweryfikuj numer telefonu', redact: 'redact', @@ -157,6 +225,10 @@ const pl = { sending: 'Wysyłanie...', resend_code: 'Ponownie wyślij kod', verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany', + please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola', + set_today: 'Ustaw Dziś', + date: 'Data', + toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy', email_has_been_sent: 'E-mail został wysłany' } @@ -172,9 +244,17 @@ const uk = { or_drag_and_drop_files: 'або перетягніть файли сюди', send_copy_via_email: 'Надіслати копію електронною поштою', download: 'Завантажити', + signature: 'Підпис', + initials: 'Ініціали', + clear: 'Очистити', + redraw: 'Перемалювати', + draw_initials: 'Намалювати ініціали', + type_signature_here: 'Введіть підпис тут', + type_initial_here: 'Введіть ініціали тут', form_has_been_completed: 'Форму заповнено!', create_a_free_account: 'Створити безкоштовний обліковий запис', signed_with: 'Підписано за допомогою', + please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити', open_source_documents_software: 'відкритий програмний засіб для документів', verified_phone_number: 'Підтвердіть номер телефону', redact: 'redact', @@ -184,6 +264,10 @@ const uk = { sending: 'Надсилаю...', resend_code: 'Повторно відправити код', verification_code_has_been_resent: 'Код підтвердження був повторно надісланий', + please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля", + set_today: 'Задати Сьогодні', + date: 'Дата', + toggle_multiline_text: 'Перемкнути Багаторядковий Текст', email_has_been_sent: 'Електронний лист був відправлений' } @@ -199,9 +283,17 @@ const cs = { or_drag_and_drop_files: 'nebo přetáhněte soubory sem', send_copy_via_email: 'Odeslat kopii e-mailem', download: 'Stáhnout', + signature: 'Podpis', + initials: 'Iniciály', + clear: 'Smazat', + redraw: 'Překreslit', + draw_initials: 'Nakreslit iniciály', + type_signature_here: 'Sem zadejte podpis', + type_initial_here: 'Sem zadejte iniciály', form_has_been_completed: 'Formulář byl dokončen!', create_a_free_account: 'Vytvořit bezplatný účet', signed_with: 'Podepsáno pomocí', + please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování', open_source_documents_software: 'open source software pro dokumenty', verified_phone_number: 'Ověřte telefonní číslo', redact: 'redact', @@ -211,6 +303,10 @@ const cs = { sending: 'Odesílání...', resend_code: 'Znovu odeslat kód', verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán', + please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky', + set_today: 'Nastavit Dnes', + date: 'Datum', + toggle_multiline_text: 'Přepnout Víceřádkový Text', email_has_been_sent: 'E-mail byl odeslán' } @@ -226,9 +322,17 @@ const pt = { or_drag_and_drop_files: 'ou arraste e solte arquivos', send_copy_via_email: 'Enviar cópia por e-mail', download: 'Baixar', + signature: 'Assinatura', + initials: 'Iniciais', + clear: 'Limpar', + redraw: 'Redesenhar', + draw_initials: 'Desenhar iniciais', + type_signature_here: 'Digite a assinatura aqui', + type_initial_here: 'Digite as iniciais aqui', form_has_been_completed: 'O formulário foi concluído!', create_a_free_account: 'Criar uma Conta Gratuita', signed_with: 'Assinado com', + please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar', open_source_documents_software: 'software de documentos de código aberto', verified_phone_number: 'Verificar Número de Telefone', redact: 'redact', @@ -238,6 +342,10 @@ const pt = { sending: 'Enviando...', resend_code: 'Reenviar código', verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS', + please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios', + set_today: 'Definir Hoje', + date: 'Data', + toggle_multiline_text: 'Alternar Texto Multilinha', email_has_been_sent: 'Email enviado' } diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue new file mode 100644 index 00000000..04a690f3 --- /dev/null +++ b/app/javascript/submission_form/initials_step.vue @@ -0,0 +1,269 @@ + + + diff --git a/app/javascript/submission_form/multi_select_step.vue b/app/javascript/submission_form/multi_select_step.vue index e00ad995..ad52f4c3 100644 --- a/app/javascript/submission_form/multi_select_step.vue +++ b/app/javascript/submission_form/multi_select_step.vue @@ -21,7 +21,7 @@ :name="`values[${field.uuid}][]`" :value="option" class="base-checkbox !h-7 !w-7" - :checked="modelValue.includes(option)" + :checked="(modelValue || []).includes(option)" @change="onChange" > diff --git a/app/javascript/submission_form/phone_step.vue b/app/javascript/submission_form/phone_step.vue index e4f4cb35..94a9634b 100644 --- a/app/javascript/submission_form/phone_step.vue +++ b/app/javascript/submission_form/phone_step.vue @@ -28,6 +28,7 @@ > @@ -83,7 +87,7 @@ ref="textInput" class="base-input !text-2xl w-full mt-6" :required="field.required" - :placeholder="`Type signature here...`" + :placeholder="`${t('type_signature_here')}...`" type="text" @input="updateWrittenSignature" > @@ -92,6 +96,10 @@ diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index 67d706cd..70355b5b 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -4,6 +4,7 @@ :style="positionStyle" @pointerdown.stop @mousedown.stop="startDrag" + @touchstart="startTouchDrag" >
Required
-
+
-
+
-
+ -
- - You need a larger screen to use builder tools. - -
-
+ +
@@ -64,6 +65,16 @@ export default { required: false, default: false }, + selectOnEditClick: { + type: Boolean, + required: false, + default: false + }, + editable: { + type: Boolean, + required: false, + default: true + }, iconStrokeWidth: { type: Number, required: false, @@ -85,6 +96,19 @@ export default { } }, methods: { + selectContent () { + const el = this.$refs.contenteditable + + const range = document.createRange() + + range.selectNodeContents(el) + + const sel = window.getSelection() + + sel.removeAllRanges() + + sel.addRange(range) + }, onBlur (e) { setTimeout(() => { this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index cc48defb..74b784ab 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -5,6 +5,7 @@ :key="image.id" :ref="setPageRefs" :number="index" + :editable="editable" :areas="areasIndex[index]" :is-drag="isDrag" :draw-field="drawField" @@ -38,6 +39,11 @@ export default { type: Object, required: true }, + editable: { + type: Boolean, + required: false, + default: true + }, drawField: { type: Object, required: false, @@ -56,8 +62,26 @@ export default { } }, computed: { + numberOfPages () { + return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length + }, sortedPreviewImages () { - return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename)) + const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata + + return [...Array(this.numberOfPages).keys()].map((i) => { + return this.previewImagesIndex[i] || { + metadata: lazyloadMetadata, + id: Math.random().toString(), + url: `/preview/${this.document.uuid}/${i}.jpg` + } + }) + }, + previewImagesIndex () { + return this.document.preview_images.reduce((acc, e) => { + acc[parseInt(e.filename)] = e + + return acc + }, {}) } }, beforeUpdate () { diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue index 7eac06c9..23aaa6d8 100644 --- a/app/javascript/template_builder/field.vue +++ b/app/javascript/template_builder/field.vue @@ -4,8 +4,9 @@ >
-
+
- + +
+ - -
- - -
import Contenteditable from './contenteditable' import FieldType from './field_type' -import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue' +import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' export default { name: 'TemplateField', components: { Contenteditable, + IconSettings, IconShape, IconNewSection, IconTrashX, + IconCopy, FieldType }, - inject: ['template', 'save'], + inject: ['template', 'save', 'backgroundColor'], props: { field: { type: Object, required: true + }, + editable: { + type: Boolean, + required: false, + default: true } }, - emits: ['set-draw', 'remove', 'move-up', 'move-down', 'scroll-to'], + emits: ['set-draw', 'remove', 'scroll-to'], data () { return { isNameFocus: false @@ -221,6 +272,23 @@ export default { } }, methods: { + copyToAllPages (field) { + const areaString = JSON.stringify(field.areas[0]) + + this.template.documents.forEach((attachment) => { + attachment.preview_images.forEach((page) => { + if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) { + field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) }) + } + }) + }) + + this.$nextTick(() => { + this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1]) + }) + + this.save() + }, onNameFocus (e) { this.isNameFocus = true @@ -237,6 +305,8 @@ export default { document.activeElement.blur() }, maybeUpdateOptions () { + delete this.field.default_value + if (!['radio', 'multiple', 'select'].includes(this.field.type)) { delete this.field.options } diff --git a/app/javascript/template_builder/field_submitter.vue b/app/javascript/template_builder/field_submitter.vue index e9583c24..55cedb58 100644 --- a/app/javascript/template_builder/field_submitter.vue +++ b/app/javascript/template_builder/field_submitter.vue @@ -1,5 +1,91 @@