diff --git a/.erb_lint.yml b/.erb_lint.yml index 1a76afa12..50576fd57 100644 --- a/.erb_lint.yml +++ b/.erb_lint.yml @@ -1,6 +1,11 @@ --- EnableDefaultLinters: true linters: + AllowedScriptType: + enabled: true + allowed_types: + - text/javascript + - application/json ErbSafety: enabled: false HardCodedString: diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index 9c7e6911b..e55cbe03d 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -173,7 +173,6 @@ module Api end def filter_submissions(submissions, params) - submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? submissions = submissions.where(slug: params[:slug]) if params[:slug].present? if params[:template_folder].present? diff --git a/app/controllers/api/submitter_email_clicks_controller.rb b/app/controllers/api/submitter_email_clicks_controller.rb index cef265422..9c52fc5b6 100644 --- a/app/controllers/api/submitter_email_clicks_controller.rb +++ b/app/controllers/api/submitter_email_clicks_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module Api - class SubmitterEmailClicksController < ApiBaseController - skip_before_action :authenticate_user! - skip_authorization_check - + class SubmitterEmailClicksController < ActionController::API def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index d9ac441ef..263af0726 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module Api - class SubmitterFormViewsController < ApiBaseController - skip_before_action :authenticate_user! - skip_authorization_check - + class SubmitterFormViewsController < ActionController::API def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 12bc306fa..c4955b3bb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :authenticate_user!, unless: :devise_controller? - before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] } + before_action :set_csp, if: lambda { + request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll? + } helper_method :button_title, :current_account, :true_ability, :form_link_host, - :svg_icon + :svg_icon, + :turbo_native_ios?, + :turbo_native_android? impersonates :user, with: ->(uuid) { User.find_by(uuid:) } @@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base private + def turbo_infinite_scroll? + request.headers['HTTP_X_TURBO_INFINITE_SCROLL'].present? + end + + def turbo_native_app? + return @turbo_native_app if defined?(@turbo_native_app) + + @turbo_native_app = request.user_agent.to_s.include?('Hotwire Native') + end + + def turbo_native_ios? + return @turbo_native_ios if defined?(@turbo_native_ios) + + @turbo_native_ios = request.user_agent.to_s.include?('Hotwire Native iOS') + end + + def turbo_native_android? + return @turbo_native_android if defined?(@turbo_native_android) + + @turbo_native_android = request.user_agent.to_s.include?('Hotwire Native Android') + end + def with_locale(&) return yield unless current_account diff --git a/app/controllers/enquiries_controller.rb b/app/controllers/enquiries_controller.rb deleted file mode 100644 index 829b578c2..000000000 --- a/app/controllers/enquiries_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# 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 702bd6e9b..48ae6d5dc 100644 --- a/app/controllers/mfa_setup_controller.rb +++ b/app/controllers/mfa_setup_controller.rb @@ -33,7 +33,7 @@ class MfaSetupController < ApplicationController def destroy RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true) - if current_user.validate_and_consume_otp!(params[:otp_attempt]) + if current_user.valid_for_authentication? { current_user.validate_and_consume_otp!(params[:otp_attempt]) } current_user.update!(otp_required_for_login: false, otp_secret: nil) redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed') diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 000000000..1b8b1a261 --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +class SearchController < ApplicationController + load_and_authorize_resource :template_folder, parent: false + load_and_authorize_resource :template, parent: false + load_and_authorize_resource :submission, parent: false + load_and_authorize_resource :template, instance_name: :filter_template, id_param: :template_id, parent: true + + FOLDERS_LIMIT = 4 + LIMIT = 12 + + def index + @query = params[:q].to_s.squish + + return render_infinite_scroll(@template_folders, @templates, @submissions, @query) if turbo_infinite_scroll? + + if @query.blank? + @template_folders = @templates = @submissions = [] + elsif params[:scope] == 'folders' + @folders_pagy, @template_folders = + pagy(:countless, filter_folders(@template_folders, @templates, @query), limit: LIMIT) + + @templates = @submissions = [] + else + folders = filter_folders(@template_folders, @templates, @query) + + @pagy, @template_folders, @templates, @submissions = + if params[:scope] == 'submissions' + load_submissions_page(folders, @templates, @submissions, @query) + else + load_templates_page(folders, @templates, @submissions, @query) + end + + if @templates.blank? && @submissions.blank? && params[:template_id].blank? + @folders_pagy, @template_folders = pagy(:countless, folders, limit: LIMIT) + end + end + end + + private + + def load_submissions_page(folders, templates, submissions, query) + pagy, submissions = pagy(:countless, filter_submissions(submissions, query), limit: LIMIT) + + return [pagy, [], [], submissions] if pagy.next || params[:template_id].present? + + [pagy, folders.limit(FOLDERS_LIMIT).to_a, filter_templates(templates, query).limit(LIMIT).to_a, submissions] + end + + def load_templates_page(folders, templates, submissions, query) + pagy, templates = pagy(:countless, filter_templates(templates, query), limit: LIMIT) + + submissions = pagy.next ? [] : filter_submissions(submissions, query).limit(LIMIT).to_a + + [pagy, folders.limit(FOLDERS_LIMIT).to_a, templates, submissions] + end + + def render_infinite_scroll(folders, templates, submissions, query) + target, partial, collection = + case params[:scope] + when 'folders' + ['search_folders', 'search/folder', filter_folders(folders, templates, query)] + when 'submissions' + ['search_submissions', 'search/submission', filter_submissions(submissions, query)] + else + ['search_templates', 'search/template', filter_templates(templates, query)] + end + + pagy, records = pagy(:countless, collection, limit: LIMIT) + + render turbo_stream: [ + turbo_stream.append(target, partial:, collection: records), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + + def filter_folders(folders, templates, query) + return folders.none if params[:archived] == 'true' + + folders = folders.where.not(id: current_account.default_template_folder.id) + + folders = folders.where(parent_folder_id: filter_folder_ids(params[:folder])) if params[:folder].present? + folders = folders.where(author_id: filter_author_id(params[:author])) if params[:author].present? + folders = TemplateFolders.filter_active_folders(folders, templates) + folders = TemplateFolders.search(folders, query) + + folders.preload(:parent_folder).order(:name) + end + + def filter_templates(templates, query) + templates = params[:archived] == 'true' ? templates.where.not(archived_at: nil) : templates.active + templates = templates.select_for_list.preload(:author, :template_accesses, folder: :parent_folder) + + templates = templates.where(folder_id: filter_folder_ids(params[:folder])) if params[:folder].present? + templates = templates.where(author_id: filter_author_id(params[:author])) if params[:author].present? + + Templates.search(current_user, templates, query).order(id: :desc) + end + + def filter_folder_ids(folder_name) + TemplateFolders.filter_by_full_name(current_account.template_folders, folder_name) + .preload(:subfolders) + .flat_map { |folder| [folder.id, *folder.subfolders.map(&:id)] } + end + + def filter_author_id(email) + current_account.users.find_by(email:)&.id || -1 + end + + def filter_submissions(submissions, query) + submissions = submissions.left_joins(:template) + + submissions = + if params[:archived] == 'true' + submissions.where.not(archived_at: nil).or(submissions.where.not(templates: { archived_at: nil })) + else + submissions.where(archived_at: nil).where(templates: { archived_at: nil }) + end + + submissions = Submissions.search(current_user, submissions, query, search_template: true) + submissions = Submissions::Filter.call(submissions, current_user, params) + + submissions = + if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present? + submissions.order(completed_at: :desc) + else + submissions.order(id: :desc) + end + + submissions.select_for_list.preload(:submitters, :template) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 2281d56f3..e1dfc0bdc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,6 +3,10 @@ class SessionsController < Devise::SessionsController before_action :configure_permitted_parameters + skip_before_action :verify_authenticity_token, if: -> { request.xhr? && request.format.json? } + + respond_to :json, only: :create + around_action :with_browser_locale def create @@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController if Docuseal.multitenant? && !User.exists?(email:) Rollbar.warning('Sign in new user') if defined?(Rollbar) - return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)), - notice: I18n.t('create_a_new_account') + respond_to do |format| + format.html do + redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)), + notice: t('create_a_new_account') + end + format.json do + render json: { sign_up: true, notice: t('create_a_new_account') }, status: :unprocessable_content + end + end + + return end if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank? - return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content + respond_to do |format| + format.html { render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content } + format.json { render json: { otp_required: true, email_otp: false }, status: :unprocessable_content } + end + + return end super diff --git a/app/controllers/submissions_archived_controller.rb b/app/controllers/submissions_archived_controller.rb index 46fa8278b..d3f1ddfda 100644 --- a/app/controllers/submissions_archived_controller.rb +++ b/app/controllers/submissions_archived_controller.rb @@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), associations: :author).call + + render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll? + end + + private + + def render_infinite_scroll(pagy, submissions) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions, + locals: { with_template: true, archived: true }), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 0f04c0dfa..8b766b25f 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController 'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan' }.freeze + STATUS_BADGES = { + 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', + 'opened' => 'badge-warning', 'declined' => 'badge-error' + }.freeze + + STATUS_ICONS = { + 'awaiting' => 'clock', 'sent' => 'send', 'completed' => 'circle_check', + 'opened' => 'mail_opened', 'declined' => 'x_circle' + }.freeze + def show @submission = Submissions.preload_with_pages(@submission) @@ -83,7 +93,14 @@ class SubmissionsController < ApplicationController I18n.t('submission_has_been_archived') end - redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:) + if turbo_native_app? && request.format.turbo_stream? + flash.now[:notice] = notice + + render turbo_stream: [turbo_stream.remove(@submission), turbo_stream.append_all('body', partial: 'shared/flash')] + else + redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, + notice:) + end end private diff --git a/app/controllers/submissions_dashboard_controller.rb b/app/controllers/submissions_dashboard_controller.rb index dea9a6f3a..91f6f04b5 100644 --- a/app/controllers/submissions_dashboard_controller.rb +++ b/app/controllers/submissions_dashboard_controller.rb @@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), associations: :author).call + + render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll? + end + + private + + def render_infinite_scroll(pagy, submissions) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions, + locals: { with_template: true }), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] end end diff --git a/app/controllers/submit_form_click_email_controller.rb b/app/controllers/submit_form_click_email_controller.rb new file mode 100644 index 000000000..bebacde43 --- /dev/null +++ b/app/controllers/submit_form_click_email_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SubmitFormClickEmailController < ActionController::API + def create + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + + if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email') + SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request) + end + + render json: {} + end +end diff --git a/app/controllers/submit_form_completed_download_controller.rb b/app/controllers/submit_form_completed_download_controller.rb index 7e244fe66..180d6f294 100644 --- a/app/controllers/submit_form_completed_download_controller.rb +++ b/app/controllers/submit_form_completed_download_controller.rb @@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController Submissions::EnsureResultGenerated.call(last_submitter) if !signature_valid && !current_user_submitter?(last_submitter) - return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) + unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true) + return head :not_found + end if last_submitter.completed_at < TTL.ago Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 7cde8f695..2f3ab0cb2 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController Submissions.preload_with_pages(submission) - Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) - @attachments_index = build_attachments_index(submission) + if current_user && current_user.email == @submitter.email + @current_user_data = current_user.as_json(only: %i[first_name last_name], methods: %i[full_name]) + + initials = UserConfigs.load_initials(current_user) + + @prefill_initials = Submitters.build_prefill_attachment_data(@submitter, initials) if initials + end + return unless @form_configs[:prefill_signature] - if (user_signature = UserConfigs.load_signature(current_user)) - @signature_attachment = ActiveStorage::Attachment.find_or_create_by!( - blob_id: user_signature.blob_id, - name: 'attachments', - record: @submitter - ) - end + signature = UserConfigs.load_signature(current_user) || + Submitters::FindRememberedSignature.call(@submitter, params, cookies) - @signature_attachment ||= - Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values) - - @attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment + @prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature end def update diff --git a/app/controllers/submit_form_delegate_controller.rb b/app/controllers/submit_form_delegate_controller.rb index da0e4ed1e..b46dd8d57 100644 --- a/app/controllers/submit_form_delegate_controller.rb +++ b/app/controllers/submit_form_delegate_controller.rb @@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController @submitter.viewer? || !Submitters::AuthorizedForForm.call(@submitter, current_user, - request) + request, + with_order: true) @submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true) diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index 821267b21..6758b2c7a 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController AccountConfig.exists?(account_id: @submitter.account_id, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, value: false) || - !Submitters::AuthorizedForForm.call(@submitter, current_user, request) + !Submitters::AuthorizedForForm.call(@submitter, current_user, request, + with_order: true) last_completed_submitter = @submitter.submission.submitters .where.not(id: @submitter.id) diff --git a/app/controllers/submit_form_draw_signature_controller.rb b/app/controllers/submit_form_draw_signature_controller.rb index 54faa7d2b..667fe42ad 100644 --- a/app/controllers/submit_form_draw_signature_controller.rb +++ b/app/controllers/submit_form_draw_signature_controller.rb @@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer? if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || - !Submitters::AuthorizedForForm.call(@submitter, current_user, request) + !Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true) return redirect_to submit_form_path(@submitter.slug) end diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb index 377f99cab..511daaf65 100644 --- a/app/controllers/submit_form_email_2fas_controller.rb +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -10,6 +10,8 @@ class SubmitFormEmail2fasController < ApplicationController def create RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) + RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true) + RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true) value = [@submitter.email.downcase.strip, @submitter.slug].join(':') diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index 62a73d9f2..55c95c830 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController !submitter.submission.expired? && !submitter.submission.template&.archived_at? && !submitter.account.archived_at? && - Submitters::AuthorizedForForm.call(submitter, current_user, request) + Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true) end end diff --git a/app/controllers/submit_form_prefill_attachments_controller.rb b/app/controllers/submit_form_prefill_attachments_controller.rb new file mode 100644 index 000000000..d56dc2a45 --- /dev/null +++ b/app/controllers/submit_form_prefill_attachments_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class SubmitFormPrefillAttachmentsController < ActionController::API + include ActiveStorage::SetCurrent + + def create + submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + + unless can_upload?(submitter) + return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content + end + + uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment) + + raise ActiveRecord::RecordNotFound if slug != submitter.slug + + source_attachment = ActiveStorage::Attachment.find_by!(uuid:) + + attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id) + + render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) + end + + private + + def can_upload?(submitter) + !submitter.declined_at? && + !submitter.completed_at? && + !submitter.submission.archived_at? && + !submitter.submission.expired? && + !submitter.submission.template&.archived_at? + end +end diff --git a/app/controllers/submit_form_upload_controller.rb b/app/controllers/submit_form_upload_controller.rb new file mode 100644 index 000000000..e25f42207 --- /dev/null +++ b/app/controllers/submit_form_upload_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class SubmitFormUploadController < ActionController::API + include ActionController::Cookies + include ActiveStorage::SetCurrent + + before_action :load_submitter + before_action :authorize_upload! + + COOKIE_STORE_LIMIT = 10 + + def create + file = params[:file] + + if params[:type].in?(%w[initials signature]) + image = ImageUtils.load_vips(file.read, content_type: file.content_type) + + if ImageUtils.blank?(image) + Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) + + return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content + end + + if ImageUtils.error?(image) + Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar) + + return render json: { error: "#{params[:type]} error, try to sign on another device" }, + status: :unprocessable_content + end + + metadata = { analyzed: true, identified: true, width: image.width, height: image.height } + end + + attachment = Submitters.create_attachment!(@submitter, file, metadata:) + + if params[:remember_signature] == 'true' && @submitter.email.present? + cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) + end + + render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) + rescue Submitters::MaliciousFileExtension => e + Rollbar.error(e) if defined?(Rollbar) + + render json: { error: e.message }, status: :unprocessable_content + end + + private + + def load_submitter + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + end + + def authorize_upload! + unless can_upload?(@submitter) + return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content + end + + return if Submitters::AuthorizedForForm.call(@submitter, current_user, request) + + render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') }, + status: :unprocessable_content + end + + def can_upload?(submitter) + !submitter.declined_at? && + !submitter.completed_at? && + !submitter.submission.archived_at? && + !submitter.submission.expired? && + !submitter.submission.template&.archived_at? + end + + def build_new_cookie_signatures_json(submitter, attachment) + values = + begin + JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}') + rescue JSON::ParserError + {} + end + + values[submitter.email] = attachment.uuid + + values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT + + values.to_json + end +end diff --git a/app/controllers/submit_form_view_controller.rb b/app/controllers/submit_form_view_controller.rb new file mode 100644 index 000000000..07c91742f --- /dev/null +++ b/app/controllers/submit_form_view_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class SubmitFormViewController < ActionController::API + def create + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + + return head :forbidden if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug + + @submitter.opened_at = Time.current + @submitter.save + + SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) + + WebhookUrls.enqueue_events(@submitter, 'form.viewed') + + render json: {} + end +end diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb index a315b1674..17fbc806f 100644 --- a/app/controllers/template_folders_controller.rb +++ b/app/controllers/template_folders_controller.rb @@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController .where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])]) .preload(:author, :template_accesses) - @template_folders = - @template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id)) - - @template_folders = TemplateFolders.search(@template_folders, params[:q]) + @template_folders = filter_folders(@template_folder) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) if @templates.exists? @@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController @templates = @templates.none end + + render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll? end def edit; end @@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController private + def filter_folders(template_folder) + template_folders = + template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id)) + + TemplateFolders.search(template_folders, params[:q]) + end + + def render_infinite_scroll(pagy, templates, template_folders) + render_params = + if templates.present? + { partial: 'templates/template', collection: templates } + else + { partial: 'template_folders/folder', collection: template_folders, as: :folder } + end + + render turbo_stream: [ + turbo_stream.before('infinite_scroll', **render_params), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + def selected_order @selected_order ||= if can?(:manage, :countless) diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index 84d40074b..7dd078b1e 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) - return unless params[:q].present? && @templates.blank? + if params[:q].present? && @templates.blank? + @related_submissions_pagy, @related_submissions = load_related_submissions + end - @related_submissions_pagy, @related_submissions = load_related_submissions + render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll? end private + def render_infinite_scroll(pagy, templates) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates, + locals: { with_folder: true }), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + def load_related_submissions related_submissions = Submission.accessible_by(current_ability) diff --git a/app/controllers/templates_archived_submissions_controller.rb b/app/controllers/templates_archived_submissions_controller.rb index 662590d08..10096268a 100644 --- a/app/controllers/templates_archived_submissions_controller.rb +++ b/app/controllers/templates_archived_submissions_controller.rb @@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController end @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) + + render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll? rescue ActiveRecord::RecordNotFound redirect_to root_path end + + private + + def render_infinite_scroll(pagy, submissions, template) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions, + locals: { template:, archived: true }), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index c45fe6cdc..e6c2e224c 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -21,6 +21,8 @@ class TemplatesController < ApplicationController @pagy, @submissions = pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events)) + + render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll? rescue ActiveRecord::RecordNotFound redirect_to root_path end @@ -81,11 +83,23 @@ class TemplatesController < ApplicationController I18n.t('template_has_been_archived') end - redirect_back(fallback_location: root_path, notice:) + if @template.destroyed? && turbo_native_app? + redirect_to templates_archived_index_path, notice: + else + redirect_back(fallback_location: root_path, notice:) + end end private + def render_infinite_scroll(pagy, submissions, template) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions, + locals: { template:, archived: template.archived_at? }), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + def template_params params.require(:template).permit( :name, diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index d9a683c95..32ca3fe39 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController @related_submissions_pagy, @related_submissions = load_related_submissions end end + + render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll? end private + def render_infinite_scroll(pagy, templates, template_folders) + render_params = + if templates.present? + { partial: 'templates/template', collection: templates } + else + { partial: 'template_folders/folder', collection: template_folders, as: :folder } + end + + render turbo_stream: [ + turbo_stream.before('infinite_scroll', **render_params), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + def load_templates(templates, folders_count, show_shared_inline: false) templates = templates.preload(:author, :template_accesses) @@ -94,16 +110,16 @@ class TemplatesDashboardController < ApplicationController pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0) - pagy = Pagy::Offset.new(count: folders_count + pinned_count, - page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, - limit: FOLDERS_PER_PAGE, - raise_range_error: true) + pagy, = pagy(template_folders, count: folders_count + pinned_count, + page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, + limit: FOLDERS_PER_PAGE, + raise_range_error: true) show_default_folder = show_default_in_grid && pagy.page == 1 show_shared_folder = show_shared_in_grid && pagy.page == 1 folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count - folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE + folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit template_folders = template_folders.offset(folder_offset).limit(folder_limit) diff --git a/app/controllers/templates_shared_controller.rb b/app/controllers/templates_shared_controller.rb index 8855d94de..178f49945 100644 --- a/app/controllers/templates_shared_controller.rb +++ b/app/controllers/templates_shared_controller.rb @@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) - return unless params[:q].present? && @templates.blank? + if params[:q].present? && @templates.blank? + @related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived) + end - @related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived) + render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll? end private + def render_infinite_scroll(pagy, templates) + render turbo_stream: [ + turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates), + turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: }) + ] + end + def load_related_submissions(is_archived:) shared_templates = Templates.shared(current_user) shared_templates = is_archived ? shared_templates.archived : shared_templates.active diff --git a/app/controllers/testing_api_settings_controller.rb b/app/controllers/testing_api_settings_controller.rb index 6c34fed7f..75269175a 100644 --- a/app/controllers/testing_api_settings_controller.rb +++ b/app/controllers/testing_api_settings_controller.rb @@ -2,6 +2,8 @@ class TestingApiSettingsController < ApplicationController def index + raise CanCan::AccessDenied unless current_account.testing? + authorize!(:manage, current_user.access_token) @webhook_url = current_account.webhook_urls.first_or_initialize diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0b6113816..d2371aa55 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -20,7 +20,7 @@ class UsersController < ApplicationController respond_to do |format| format.html do - @pagy, @users = pagy(@users) + @pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit]) end if current_ability.can?(:manage, current_account) diff --git a/app/javascript/application.js b/app/javascript/application.js index 6e8eec576..7dee5edf9 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,5 +1,5 @@ -import '@hotwired/turbo' -import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests' +import '@hotwired/turbo-rails' +import './native' import { createApp, reactive } from 'vue' import TemplateBuilder from './template_builder/builder' @@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible' import ToggleCookies from './elements/toggle_cookies' import DisableHidden from './elements/disable_hidden' import TurboModal from './elements/turbo_modal' +import NativeAction from './elements/native_action' +import NativeMenu from './elements/native_menu' +import NativeSearch from './elements/native_search' +import NativeModal from './elements/native_modal' +import NativeEvent from './elements/native_event' +import NativeUpload from './elements/native_upload' +import FormPrompt from './elements/form_prompt' +import InfiniteScroll from './elements/infinite_scroll' +import ModalButton from './elements/modal_button' import FileDropzone from './elements/file_dropzone' import MenuActive from './elements/menu_active' import ClipboardCopy from './elements/clipboard_copy' @@ -59,10 +68,8 @@ import ConfirmUpload from './elements/confirm_upload' import ScrollFade from './elements/scroll_fade' import OpenModalMobile from './elements/open_modal_mobile' import HistoryBack from './elements/history_back' - -import * as TurboInstantClick from './lib/turbo_instant_click' - -TurboInstantClick.start() +import DatePlaceholder from './elements/date_placeholder' +import FlashMessage from './elements/flash_message' document.addEventListener('turbo:before-cache', () => { window.flash?.remove() @@ -74,10 +81,18 @@ document.addEventListener('keyup', (e) => { } }) -document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) -document.addEventListener('turbo:before-fetch-request', (event) => { - event.detail.fetchOptions.headers['X-Turbo'] = 'true' +document.addEventListener('turbo:morph', () => { + if (document.activeElement?.closest('.dropdown')) { + document.activeElement.blur() + } }) + +document.addEventListener('turbo:before-fetch-response', (event) => { + if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) { + event.preventDefault() + } +}) + document.addEventListener('turbo:submit-end', async (event) => { const resp = event.detail?.formSubmission?.result?.fetchResponse?.response @@ -85,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => { return } + const filename = decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]) + const download = window.webkit?.messageHandlers?.download + + if (download) { + const reader = new FileReader() + + reader.onload = () => download.postMessage({ name: filename, data: reader.result.split(',')[1] }) + reader.readAsDataURL(await resp.blob()) + + return + } + const url = URL.createObjectURL(await resp.blob()) const link = document.createElement('a') link.href = url - link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])) + link.setAttribute('download', filename) document.body.appendChild(link) @@ -105,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme safeRegisterElement('toggle-visible', ToggleVisible) safeRegisterElement('disable-hidden', DisableHidden) safeRegisterElement('turbo-modal', TurboModal) +safeRegisterElement('native-action', NativeAction) +safeRegisterElement('native-menu', NativeMenu) +safeRegisterElement('native-search', NativeSearch) +safeRegisterElement('native-modal', NativeModal) +safeRegisterElement('native-event', NativeEvent) +safeRegisterElement('native-upload', NativeUpload) +safeRegisterElement('form-prompt', FormPrompt) +safeRegisterElement('infinite-scroll', InfiniteScroll) +safeRegisterElement('modal-button', ModalButton) safeRegisterElement('file-dropzone', FileDropzone) safeRegisterElement('menu-active', MenuActive) safeRegisterElement('clipboard-copy', ClipboardCopy) @@ -156,16 +192,30 @@ safeRegisterElement('confirm-upload', ConfirmUpload) safeRegisterElement('scroll-fade', ScrollFade) safeRegisterElement('open-modal-mobile', OpenModalMobile) safeRegisterElement('history-back', HistoryBack) +safeRegisterElement('date-placeholder', DatePlaceholder) +safeRegisterElement('flash-message', FlashMessage) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { document.addEventListener('turbo:submit-end', this.onSubmit) + document.addEventListener('template-builder:update', this.onSheetSubmit) + document.addEventListener('template-builder:sync', this.onSync) + document.addEventListener('template-builder:scroll-to', this.onScrollTo) + document.addEventListener('template-builder:select-field', this.onSelectField) + document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter) + document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields) + document.addEventListener('template-builder:draw-field', this.onDrawField) + document.addEventListener('native:builder-view', this.onBuilderView) document.addEventListener('turbo:before-cache', this.onBeforeCache) this.appElem = document.createElement('div') this.appElem.classList.add('md:h-screen') + if (this.dataset.nativeView) { + this.appElem.classList.add('min-w-0') + } + const template = reactive(JSON.parse(this.dataset.template)) this.app = createApp(TemplateBuilder, { @@ -203,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement { withDownload: true, currencies: (this.dataset.currencies || '').split(',').filter(Boolean), acceptFileTypes: this.dataset.acceptFileTypes, - showTourStartForm: this.dataset.showTourStartForm === 'true' + showTourStartForm: this.dataset.showTourStartForm === 'true', + nativePlatform: this.dataset.nativePlatform, + nativeView: this.dataset.nativeView }) this.component = this.app.mount(this.appElem) @@ -211,39 +263,130 @@ safeRegisterElement('template-builder', class extends HTMLElement { this.appendChild(this.appElem) } - onSubmit = (e) => { - if (e.detail.success) { - if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') { - e.detail.fetchResponse.response.json().then((data) => { - this.component.template.submitters = data.submitters - }) + onSubmit = async (e) => { + const form = e.detail.formSubmission?.formElement + + if (!e.detail.success || !form) return + + const data = {} + + new FormData(form).forEach((value, key) => { data[key] = value }) + + this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data) + } + + onSheetSubmit = (e) => { + this.applySubmission(e.detail.form, e.detail.action, e.detail.body, {}) + this.component.syncNative() + } + + onSync = (e) => { + const { schema, documents, fields, submitters, pendingFieldAttachmentUuids } = e.detail + const selectedAreas = this.component.selectedAreasRef.value.map((area) => { + const field = this.component.template.fields.find((f) => f.areas?.includes(area)) + + return field && { uuid: field.uuid, index: field.areas.indexOf(area) } + }) + const selectedSubmitterUuid = this.component.selectedSubmitter?.uuid + + Object.assign(this.component.template, { schema, documents, fields, submitters }) + + this.component.selectedAreasRef.value = selectedAreas.map((item) => item && fields.find((f) => f.uuid === item.uuid)?.areas?.[item.index]).filter(Boolean) + this.component.selectedSubmitter = submitters.find((s) => s.uuid === selectedSubmitterUuid) || submitters[0] + this.component.pendingFieldAttachmentUuids = pendingFieldAttachmentUuids + } + + onScrollTo = (e) => { + this.component.scrollIntoDocument(e.detail) + } + + onSyncCustomFields = (e) => { + this.component.customFields.splice(0, this.component.customFields.length, ...e.detail) + } + + onSelectSubmitter = (e) => { + const submitter = this.component.template.submitters.find((s) => s.uuid === e.detail.uuid) + + if (submitter) this.component.selectedSubmitter = submitter + } + + onSelectField = (e) => { + const { uuid, attachment_uuid: attachmentUuid, page, x, y } = e.detail + const field = this.component.template.fields.find((f) => f.uuid === uuid) + const areas = field?.areas || [] + const area = areas.find((a) => a.attachment_uuid === attachmentUuid && a.page === page && a.x === x && a.y === y) || areas[0] + + if (area) this.component.scrollToArea(area) + } + + onDrawField = (e) => { + const { uuid, type, option, custom_field_uuid: customFieldUuid } = e.detail + + if (customFieldUuid) { + const field = this.component.customFields.find((f) => f.uuid === customFieldUuid) + + if (field) { + this.component.drawCustomField = field + this.component.showDrawField = true } + } else if (uuid) { + const field = this.component.template.fields.find((f) => f.uuid === uuid) - if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) { - e.detail.fetchResponse.response.text().then((data) => { - const doc = new DOMParser().parseFromString(data, 'text/html') - const fragment = doc.querySelector('turbo-stream template').content - - const prefillableUuidsIndex = {} - - fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => { - prefillableUuidsIndex[field.value] = true - }) - - this.component.template.fields.forEach((field) => { - if (prefillableUuidsIndex[field.uuid]) { - field.prefillable = true - field.readonly = true - } else if (field.prefillable) { - delete field.prefillable - delete field.readonly - } - }) - }) + if (field) { + this.component.drawField = field + this.component.drawOption = option || null } + } else if (type) { + this.component.startFieldDraw({ type }) } } + applySubmission (formId, action, body, data) { + if (formId === 'submitters_form') { + this.component.template.submitters = JSON.parse(body).submitters + } else if (action.endsWith('/prefillable_fields')) { + const doc = new DOMParser().parseFromString(body, 'text/html') + const fragment = doc.querySelector('turbo-stream template').content + + const prefillableUuidsIndex = {} + + fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => { + prefillableUuidsIndex[field.value] = true + }) + + this.component.template.fields.forEach((field) => { + if (prefillableUuidsIndex[field.uuid]) { + field.prefillable = true + field.readonly = true + } else if (field.prefillable) { + delete field.prefillable + delete field.readonly + } + }) + } else if (data['template[name]']) { + this.component.template.name = data['template[name]'] + + document.title = data['template[name]'] + } + } + + onBuilderView = (event) => { + const root = document.documentElement.cloneNode(true) + const builder = root.querySelector('template-builder') + const style = document.createElement('style') + + builder.innerHTML = '' + builder.setAttribute('data-native-view', event.detail.view) + + root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no') + + style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; } body { -webkit-user-select: none; user-select: none; } input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }' + + root.querySelector('head').appendChild(style) + + event.detail.result = '' + root.outerHTML + } + onBeforeCache = () => { this.app?.unmount() this.appElem?.remove() @@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement { disconnectedCallback () { document.removeEventListener('turbo:submit-end', this.onSubmit) + document.removeEventListener('template-builder:update', this.onSheetSubmit) + document.removeEventListener('template-builder:sync', this.onSync) + document.removeEventListener('template-builder:scroll-to', this.onScrollTo) + document.removeEventListener('template-builder:select-field', this.onSelectField) + document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter) + document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields) + document.removeEventListener('template-builder:draw-field', this.onDrawField) + document.removeEventListener('native:builder-view', this.onBuilderView) document.removeEventListener('turbo:before-cache', this.onBeforeCache) this.app?.unmount() diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 1050df66c..f1b2c9995 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled { markdown-editor [contenteditable] p { margin-bottom: 18px; } + +native-action { + display: contents; +} + +@media (max-width: 767px) { + .table-stack, + .table-stack tbody, + .table-stack tr, + .table-stack td { + display: block; + } + + .table-stack, + div:has(> .table-stack) { + overflow: visible; + } + + .table-stack thead { + display: none; + } + + .table-stack tr { + @apply bg-base-200 rounded-2xl py-3 mb-3 border-0; + } + + .table-stack td { + @apply px-4 py-1; + } + + .table-stack td[data-label] { + @apply flow-root text-right; + overflow-wrap: anywhere; + } + + .table-stack td[data-label]::before { + content: attr(data-label); + @apply float-left mr-4 text-sm leading-6 text-base-content/60; + } + + .table-stack td:first-child { + @apply font-semibold; + } + + .table-stack td:last-child { + @apply grid grid-cols-2 gap-2 pt-3; + } + + .table-stack td:last-child:has(> .dropdown) { + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + } + + .table-stack td:last-child:not(:has(*)) { + display: none; + } + + .table-stack td:last-child > * { + margin-left: 0 !important; + } + + .table-stack td:last-child > :last-child:nth-child(odd):not(.dropdown) { + @apply col-span-2; + } + + .table-stack td:last-child .btn { + @apply btn-sm w-full; + } +} diff --git a/app/javascript/draw.js b/app/javascript/draw.js index 30d493a30..b40006fc8 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement { const formData = new FormData() formData.append('file', file) - formData.append('submitter_slug', this.dataset.slug) formData.append('name', 'attachments') formData.append('remember_signature', 'true') - return fetch('/api/attachments', { + return fetch(`/s/${this.dataset.slug}/upload`, { method: 'POST', body: formData }).then(resp => resp.json()) diff --git a/app/javascript/elements/autoresize_textarea.js b/app/javascript/elements/autoresize_textarea.js index a5a8b5ed0..e33c13794 100644 --- a/app/javascript/elements/autoresize_textarea.js +++ b/app/javascript/elements/autoresize_textarea.js @@ -1,5 +1,7 @@ export default class extends HTMLElement { connectedCallback () { + this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}` + this.resize() this.textarea.addEventListener('input', () => this.resize()) diff --git a/app/javascript/elements/bar_chart.js b/app/javascript/elements/bar_chart.js index 32fdfa0fc..25302536a 100644 --- a/app/javascript/elements/bar_chart.js +++ b/app/javascript/elements/bar_chart.js @@ -1,5 +1,7 @@ export default class extends HTMLElement { connectedCallback () { + this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}` + this.chartLabels = JSON.parse(this.dataset.labels || '[]') this.chartDatasets = JSON.parse(this.dataset.datasets || '[]') diff --git a/app/javascript/elements/clipboard_copy.js b/app/javascript/elements/clipboard_copy.js index 4118b9e2f..a382f138e 100644 --- a/app/javascript/elements/clipboard_copy.js +++ b/app/javascript/elements/clipboard_copy.js @@ -4,9 +4,18 @@ export default class extends HTMLElement { this.addEventListener('click', (e) => { const text = this.dataset.text || this.innerText.trim() + const download = window.webkit?.messageHandlers?.download + + if (download && /^https?:\/\//.test(text)) { + e.preventDefault() + + return download.postMessage({ url: text }) + } if (navigator.clipboard) { - navigator.clipboard.writeText(text) + navigator.clipboard.writeText(text).then(() => { + window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied }) + }) } else { if (e.target.tagName !== 'INPUT') { alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`) diff --git a/app/javascript/elements/convert_upload.js b/app/javascript/elements/convert_upload.js index a00cd6668..0cc8f4700 100644 --- a/app/javascript/elements/convert_upload.js +++ b/app/javascript/elements/convert_upload.js @@ -59,10 +59,10 @@ export async function convertImagesInInput (input) { export default class extends HTMLElement { connectedCallback () { - const input = this.querySelector('input[type="file"]') - const form = input.form + this.addEventListener('change', async (e) => { + const input = e.target + const form = input.form - input.addEventListener('change', async () => { await convertImagesInInput(input) form.querySelector('[type="submit"]')?.setAttribute('disabled', true) diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 434b33b7a..8aa75c0f6 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement { window.addEventListener('dragleave', this.onWindowDragleave) - this.fileDropzone?.addEventListener('drop', this.onDropFile) - this.folderDropzone?.addEventListener('drop', this.onDropNewFolder) + this.addEventListener('drop', this.onDrop) + this.addEventListener('dragstart', this.onDragstart) + this.addEventListener('dragover', this.onDragover) + this.addEventListener('dragleave', this.onDragleave) + } - this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el))) - this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) - this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) + dropTarget (e) { + return e.target.closest([ + '[data-target="dashboard-dropzone.fileDropzone"]', + '[data-target="dashboard-dropzone.folderDropzone"]', + '[data-targets="dashboard-dropzone.folderCards"]', + '[data-targets="dashboard-dropzone.templateCards"]' + ].join(', ')) + } - return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => { - el?.addEventListener('dragover', this.onDragover) - el?.addEventListener('dragleave', this.onDragleave) - }) + onDrop = (e) => { + const el = this.dropTarget(e) + + if (!el) return + + if (el === this.fileDropzone) { + this.onDropFile(e) + } else if (el === this.folderDropzone) { + this.onDropNewFolder(e) + } else if (el.dataset.targets === 'dashboard-dropzone.folderCards') { + this.onDropFolder(e, el) + } else { + this.onDropTemplate(e, el) + } + } + + onDragstart = (e) => { + if (e.target.closest('[data-targets="dashboard-dropzone.templateCards"]')) { + this.onTemplateDragStart(e) + } } disconnectedCallback () { @@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement { } }).finally(() => { window.Turbo.cache.clear() - window.Turbo.visit(location.href) + window.Turbo.visit(location.href, { action: 'replace' }) }) } } } - onDropTemplate = (e) => { + onDropTemplate = (e, el) => { e.preventDefault() if (e.dataTransfer.files.length) { @@ -136,10 +160,10 @@ export default targetable(class extends HTMLElement { loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute') loading.innerHTML = loadingIconHtml - e.target.appendChild(loading) - e.target.classList.add('opacity-50') + el.appendChild(loading) + el.classList.add('opacity-50') - const id = e.target.href.split('/').pop() + const id = el.href.split('/').pop() this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`) } @@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement { } } - onDragover (e) { - if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') { - this.style.backgroundColor = '#F7F3F0' + onDragover = (e) => { + const el = this.dropTarget(e) - if (this.classList.contains('before:border-base-300')) { - this.classList.remove('before:border-base-300') - this.classList.add('before:border-base-content/30') - } else if (this.classList.contains('border-base-300')) { - this.classList.remove('border-base-300') - this.classList.add('border-base-content/30') + if (!el) return + + if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') { + el.style.backgroundColor = '#F7F3F0' + + if (el.classList.contains('before:border-base-300')) { + el.classList.remove('before:border-base-300') + el.classList.add('before:border-base-content/30') + } else if (el.classList.contains('border-base-300')) { + el.classList.remove('border-base-300') + el.classList.add('border-base-content/30') } } } @@ -205,15 +233,19 @@ export default targetable(class extends HTMLElement { a.remove() } - onDragleave () { - this.style.backgroundColor = null + onDragleave = (e) => { + const el = this.dropTarget(e) - if (this.classList.contains('before:border-base-content/30')) { - this.classList.remove('before:border-base-content/30') - this.classList.add('before:border-base-300') - } else if (this.classList.contains('border-base-content/30')) { - this.classList.remove('border-base-content/30') - this.classList.add('border-base-300') + if (!el) return + + el.style.backgroundColor = null + + if (el.classList.contains('before:border-base-content/30')) { + el.classList.remove('before:border-base-content/30') + el.classList.add('before:border-base-300') + } else if (el.classList.contains('border-base-content/30')) { + el.classList.remove('border-base-content/30') + el.classList.add('border-base-300') } } diff --git a/app/javascript/elements/date_placeholder.js b/app/javascript/elements/date_placeholder.js new file mode 100644 index 000000000..8003b2adf --- /dev/null +++ b/app/javascript/elements/date_placeholder.js @@ -0,0 +1,10 @@ +export default class extends HTMLElement { + connectedCallback () { + const input = this.querySelector('input') + const placeholder = this.querySelector('span') + + input.addEventListener('change', () => { + placeholder.classList.toggle('hidden', !!input.value) + }) + } +} diff --git a/app/javascript/elements/disable_hidden.js b/app/javascript/elements/disable_hidden.js index 1e5da77c7..4b1c621ba 100644 --- a/app/javascript/elements/disable_hidden.js +++ b/app/javascript/elements/disable_hidden.js @@ -2,6 +2,18 @@ export default class extends HTMLElement { static observedAttributes = ['class'] connectedCallback () { + this.addEventListener('turbo:morph-element', this.onMorph) + + this.trigger() + } + + disconnectedCallback () { + this.removeEventListener('turbo:morph-element', this.onMorph) + } + + onMorph = (event) => { + if (event.target !== this) return + this.trigger() } diff --git a/app/javascript/elements/download_button.js b/app/javascript/elements/download_button.js index 2a4b62104..09ac52fa6 100644 --- a/app/javascript/elements/download_button.js +++ b/app/javascript/elements/download_button.js @@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement { downloadFiles () { if (!this.dataset.src) return + const download = window.webkit?.messageHandlers?.download + + if (download) { + this.toggleState() + + document.addEventListener('native:download-end', () => this.toggleState(), { once: true }) + + return download.postMessage({ src: this.dataset.src }) + } + this.toggleState() fetch(this.dataset.src).then(async (response) => { diff --git a/app/javascript/elements/email_editor.js b/app/javascript/elements/email_editor.js index 030eeaa42..ad04ef8c1 100644 --- a/app/javascript/elements/email_editor.js +++ b/app/javascript/elements/email_editor.js @@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement { ] connectedCallback () { + this.id = `email-editor-${Math.random().toString(32).split('.')[1]}` + this.mount() if (this.input.value) { diff --git a/app/javascript/elements/emails_textarea.js b/app/javascript/elements/emails_textarea.js index dde8109f7..ea5e94460 100644 --- a/app/javascript/elements/emails_textarea.js +++ b/app/javascript/elements/emails_textarea.js @@ -35,7 +35,7 @@ export default class extends HTMLElement { counter.innerText = `${count} / ${this.dataset.limit}` } - if (this.dataset.bulkEnabled !== 'true') { + if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) { if (!bulkMessage) { bulkMessage = document.createElement('span') diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index fb8c1ae75..ad7843e6f 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement { this.addEventListener('dragover', (e) => e.preventDefault()) this.addEventListener('drop', this.onDrop) document.addEventListener('turbo:submit-end', this.toggleLoading) + + if (this.area) { + this.initArea() + } else { + queueMicrotask(() => this.initArea()) + } + } + + initArea () { this.area?.addEventListener('dragover', this.onDragover) this.area?.addEventListener('dragleave', this.onDragleave) } diff --git a/app/javascript/elements/flash_message.js b/app/javascript/elements/flash_message.js new file mode 100644 index 000000000..d3dca79e9 --- /dev/null +++ b/app/javascript/elements/flash_message.js @@ -0,0 +1,43 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('turbo:morph-element', this.onMorph) + + this.show() + } + + disconnectedCallback () { + this.removeEventListener('turbo:morph-element', this.onMorph) + + clearTimeout(this.closeTimeout) + } + + onMorph = (event) => { + if (event.target !== this) return + + this.show() + } + + show () { + clearTimeout(this.closeTimeout) + + this.hideAnimation?.cancel() + + this.animate([ + { transform: 'translateY(-100%)', opacity: 0 }, + { transform: 'translateY(0)', opacity: 1 } + ], { duration: 200, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }) + + this.closeTimeout = setTimeout(this.close, 2000) + } + + close = () => { + clearTimeout(this.closeTimeout) + + this.hideAnimation = this.animate([ + { transform: 'translateY(0)', opacity: 1 }, + { transform: 'translateY(-100%)', opacity: 0 } + ], { duration: 150, easing: 'ease-in', fill: 'forwards' }) + + this.hideAnimation.onfinish = () => this.remove() + } +} diff --git a/app/javascript/elements/form_prompt.js b/app/javascript/elements/form_prompt.js new file mode 100644 index 000000000..b04219779 --- /dev/null +++ b/app/javascript/elements/form_prompt.js @@ -0,0 +1,33 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('click', this.prompt) + document.addEventListener('turbo:submit-end', this.onSubmitEnd) + } + + disconnectedCallback () { + this.removeEventListener('click', this.prompt) + document.removeEventListener('turbo:submit-end', this.onSubmitEnd) + } + + get form () { + return this.querySelector('form') + } + + prompt = () => { + const label = this.form.querySelector('label') + const input = this.form.querySelector(`#${CSS.escape(label.htmlFor)}`) + const value = window.prompt(label.textContent.trim(), input.value)?.trim() + + if (!value || value === input.value) return + + input.value = value + + this.form.requestSubmit() + } + + onSubmitEnd = (e) => { + if (e.detail.success && e.detail.formSubmission?.formElement === this.form && this.dataset.refresh !== 'false') { + window.Turbo.visit(window.location.href, { action: 'replace' }) + } + } +} diff --git a/app/javascript/elements/html_editor.js b/app/javascript/elements/html_editor.js index 0c851ee5e..a999436a7 100644 --- a/app/javascript/elements/html_editor.js +++ b/app/javascript/elements/html_editor.js @@ -458,6 +458,8 @@ export default actionable(targetable(class extends HTMLElement { async connectedCallback () { if (!this.textarea || !this.editorElement) return + this.id = `html-editor-${Math.random().toString(32).split('.')[1]}` + this.textarea.style.display = 'none' this.adjustShortcutsForPlatform() diff --git a/app/javascript/elements/infinite_scroll.js b/app/javascript/elements/infinite_scroll.js new file mode 100644 index 000000000..e6a4e775e --- /dev/null +++ b/app/javascript/elements/infinite_scroll.js @@ -0,0 +1,52 @@ +import { renderStreamMessage, session } from '@hotwired/turbo' + +export default class extends HTMLElement { + connectedCallback () { + if (this.dataset.limit) this.replaceLimitParam() + + if (!this.dataset.src) { + this.remove() + + return + } + + this.observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this.load() + } + }, { rootMargin: '400px' }) + + this.observer.observe(this) + } + + disconnectedCallback () { + this.observer?.disconnect() + } + + replaceLimitParam () { + const url = new URL(window.location.href) + + url.searchParams.set('limit', this.dataset.limit) + + session.history.replace(url, session.history.restorationIdentifier) + session.view.lastRenderedLocation = url + } + + load () { + if (this.loading || !this.dataset.src) return + + this.loading = true + + fetch(this.dataset.src, { + headers: { 'X-Turbo-Infinite-Scroll': 'true' } + }).then(async (response) => { + if (!response.ok) return + + delete this.dataset.src + + renderStreamMessage(await response.text()) + }).finally(() => { + this.loading = false + }) + } +} diff --git a/app/javascript/elements/markdown_editor.js b/app/javascript/elements/markdown_editor.js index 9e31737cf..479be4e4f 100644 --- a/app/javascript/elements/markdown_editor.js +++ b/app/javascript/elements/markdown_editor.js @@ -143,6 +143,8 @@ export default actionable(targetable(class extends HTMLElement { async connectedCallback () { if (!this.textarea || !this.editorElement) return + this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}` + this.textarea.style.display = 'none' this.adjustShortcutsForPlatform() diff --git a/app/javascript/elements/masked_input.js b/app/javascript/elements/masked_input.js index 14ee29ff0..0dde13721 100644 --- a/app/javascript/elements/masked_input.js +++ b/app/javascript/elements/masked_input.js @@ -1,14 +1,13 @@ export default class extends HTMLElement { connectedCallback () { - const maskedToken = this.input.value - this.input.addEventListener('focus', () => { + this.maskedToken = this.input.value this.input.value = this.dataset.token this.input.select() }) this.input.addEventListener('focusout', () => { - this.input.value = maskedToken + this.input.value = this.maskedToken }) } diff --git a/app/javascript/elements/menu_active.js b/app/javascript/elements/menu_active.js index 819e92592..e71d55f30 100644 --- a/app/javascript/elements/menu_active.js +++ b/app/javascript/elements/menu_active.js @@ -1,5 +1,7 @@ export default class extends HTMLElement { connectedCallback () { + this.id = `menu-active-${Math.random().toString(32).split('.')[1]}` + this.querySelectorAll('a').forEach((link) => { if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) { link.classList.add('bg-base-300') diff --git a/app/javascript/elements/native_action.js b/app/javascript/elements/native_action.js new file mode 100644 index 000000000..97d3b5225 --- /dev/null +++ b/app/javascript/elements/native_action.js @@ -0,0 +1,44 @@ +import { nativeIcon } from '../native' + +export default class extends HTMLElement { + connectedCallback () { + const bridge = window.webkit?.messageHandlers?.native + + if (!bridge) return + + const target = this.querySelector('a, button, [role="button"]') || this.firstElementChild + + if (!target && !this.dataset.native) return + + this.nativeId ||= `native-action-${Math.random().toString(32).split('.')[1]}` + this.id = this.nativeId + + if (target) target.dataset.nativeId = this.nativeId + + bridge.postMessage({ + type: 'action', + op: 'add', + id: this.nativeId, + title: this.dataset.label || target?.textContent.trim(), + icon: nativeIcon(this.dataset.icon), + placement: this.dataset.placement || 'menu', + side: this.dataset.side, + view: this.dataset.view, + section: this.dataset.section, + menu: this.dataset.menu, + menuIcon: nativeIcon(this.dataset.menuIcon), + native: this.dataset.native, + url: this.dataset.url, + accept: this.dataset.accept, + destructive: this.dataset.destructive === 'true', + selected: this.dataset.selected === 'true', + haptic: this.dataset.haptic === 'true' + }) + } + + disconnectedCallback () { + if (this.nativeId) { + window.webkit?.messageHandlers?.native?.postMessage({ type: 'action', op: 'remove', id: this.nativeId }) + } + } +} diff --git a/app/javascript/elements/native_event.js b/app/javascript/elements/native_event.js new file mode 100644 index 000000000..8faba14d2 --- /dev/null +++ b/app/javascript/elements/native_event.js @@ -0,0 +1,60 @@ +export default class extends HTMLElement { + connectedCallback () { + if (this.dataset.on === 'submit') { + document.addEventListener('turbo:submit-end', this.onSubmitEnd) + this.addEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse) + } else { + this.addEventListener('click', this.dispatch) + } + } + + disconnectedCallback () { + document.removeEventListener('turbo:submit-end', this.onSubmitEnd) + this.removeEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse) + this.removeEventListener('click', this.dispatch) + } + + onBeforeFetchResponse = (e) => { + const response = e.detail.fetchResponse + + if (window.webkit?.messageHandlers?.modal && response.succeeded && !response.redirected && !response.isHTML) { + e.preventDefault() + } + } + + onSubmitEnd = async (e) => { + const form = e.detail.formSubmission?.formElement + + if (!e.detail.success || !form || !this.contains(form)) return + + this.post(JSON.stringify({ form: form.id, action: form.action, body: await e.detail.fetchResponse.responseText })) + } + + dispatch = async () => { + if (this.dataset.loading) return + + let detail = this.dataset.detail || 'null' + + if (this.dataset.src) { + this.dataset.loading = 'true' + + try { + const response = await fetch(this.dataset.src, { headers: { Accept: 'application/json' } }) + + if (!response.ok) return + + detail = await response.text() + } catch { + return + } finally { + delete this.dataset.loading + } + } + + this.post(detail) + } + + post (detail) { + window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: this.dataset.name, detail, dismiss: this.dataset.dismiss || 'true' }) + } +} diff --git a/app/javascript/elements/native_menu.js b/app/javascript/elements/native_menu.js new file mode 100644 index 000000000..d9c2f7b86 --- /dev/null +++ b/app/javascript/elements/native_menu.js @@ -0,0 +1,49 @@ +import { nativeIcon } from '../native' + +export default class extends HTMLElement { + connectedCallback () { + if (window.webkit?.messageHandlers?.native) { + this.addEventListener('click', this.onClick) + } else { + document.addEventListener('pointerdown', this.onPointerDown) + } + } + + disconnectedCallback () { + this.removeEventListener('click', this.onClick) + document.removeEventListener('pointerdown', this.onPointerDown) + } + + onPointerDown = (event) => { + if (this.contains(document.activeElement) && !this.contains(event.target)) { + document.activeElement.blur() + } + } + + onClick = (event) => { + if (event.target.closest('native-menu-action')) return + + const rect = this.firstElementChild.getBoundingClientRect() + + const items = [...this.querySelectorAll('native-menu-action')].map((action) => { + const target = action.querySelector('a, button, [role="button"]') || action.firstElementChild + + target.dataset.nativeId ||= `native-menu-${Math.random().toString(32).split('.')[1]}` + + return { + id: target.dataset.nativeId, + title: action.dataset.label || target.textContent.trim(), + icon: nativeIcon(action.dataset.icon), + destructive: action.dataset.destructive === 'true', + selected: action.dataset.selected === 'true', + haptic: action.dataset.haptic === 'true' + } + }) + + window.webkit.messageHandlers.native.postMessage({ + type: 'menu', + rect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, width: rect.width, height: rect.height }, + items + }) + } +} diff --git a/app/javascript/elements/native_modal.js b/app/javascript/elements/native_modal.js new file mode 100644 index 000000000..e412c5af4 --- /dev/null +++ b/app/javascript/elements/native_modal.js @@ -0,0 +1,41 @@ +export default class extends HTMLElement { + connectedCallback () { + document.addEventListener('turbo:submit-end', this.onSubmit) + this.addEventListener('click', this.onClick) + } + + disconnectedCallback () { + document.removeEventListener('turbo:submit-end', this.onSubmit) + } + + onSubmit = async (e) => { + if (!e.detail.success) return + if (e.detail.fetchResponse?.redirected && e.detail.fetchResponse.location.href !== window.location.href) return + if (e.detail.formSubmission?.formElement?.method === 'get') return + if (this.dataset.closeAfterSubmit === 'false') return + if (e.detail.formSubmission?.formElement?.dataset?.closeOnSubmit === 'false') return + if (e.detail.formSubmission?.formElement?.closest('native-event[data-on="submit"]')) return + + const flash = await this.flashFrom(e.detail.fetchResponse) + + window.webkit?.messageHandlers?.modal?.postMessage({ action: 'submitted', ...flash }) + } + + async flashFrom (fetchResponse) { + const html = await fetchResponse?.responseHTML + const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash') + + if (flash?.dataset.alert) return { alert: flash.dataset.alert } + if (flash?.dataset.notice) return { notice: flash.dataset.notice } + + return {} + } + + onClick = () => { + setTimeout(() => { + if (document.documentElement.scrollHeight > window.innerHeight) { + window.webkit?.messageHandlers?.modal?.postMessage({ action: 'expand' }) + } + }, 300) + } +} diff --git a/app/javascript/elements/native_search.js b/app/javascript/elements/native_search.js new file mode 100644 index 000000000..aa9b00f1a --- /dev/null +++ b/app/javascript/elements/native_search.js @@ -0,0 +1,59 @@ +export default class extends HTMLElement { + connectedCallback () { + document.addEventListener('native:search', this.onSearch) + document.addEventListener('turbo:before-cache', this.onBeforeCache) + this.addEventListener('touchstart', this.onTouchStart, { passive: true }) + this.addEventListener('scroll', this.onScroll, { capture: true, passive: true }) + + const scroller = this.querySelector('[data-scroll-top]') + + if (scroller) scroller.scrollTop = scroller.dataset.scrollTop + } + + disconnectedCallback () { + document.removeEventListener('native:search', this.onSearch) + document.removeEventListener('turbo:before-cache', this.onBeforeCache) + } + + onBeforeCache = () => { + if (this.scroller) this.scroller.dataset.scrollTop = this.scroller.scrollTop + } + + onTouchStart = () => { + this.isTouching = true + } + + onScroll = (event) => { + this.scroller = event.target + + if (!this.isTouching) return + + this.isTouching = false + + window.webkit?.messageHandlers?.native?.postMessage({ type: 'dismiss-keyboard' }) + } + + onSearch = (event) => { + const query = (event.detail?.q || '').trim() + + if (query === (this.dataset.q || '')) return + + this.dataset.q = query + + const url = new URL(this.frame.src || this.dataset.src, window.location.href) + + url.searchParams.delete('page') + + if (query) { + url.searchParams.set('q', query) + } else { + url.searchParams.delete('q') + } + + this.frame.src = url.toString() + } + + get frame () { + return this.querySelector('turbo-frame') + } +} diff --git a/app/javascript/elements/native_upload.js b/app/javascript/elements/native_upload.js new file mode 100644 index 000000000..869f74b6d --- /dev/null +++ b/app/javascript/elements/native_upload.js @@ -0,0 +1,61 @@ +import { convertImagesInInput } from './convert_upload' + +export default class extends HTMLElement { + connectedCallback () { + this.files = [] + + document.addEventListener('turbo:submit-end', this.onSubmitEnd) + document.addEventListener('native:upload-start', this.onUploadStart) + document.addEventListener('native:upload-chunk', this.onUploadChunk) + document.addEventListener('native:upload-submit', this.onUploadSubmit) + } + + disconnectedCallback () { + document.removeEventListener('turbo:submit-end', this.onSubmitEnd) + document.removeEventListener('native:upload-start', this.onUploadStart) + document.removeEventListener('native:upload-chunk', this.onUploadChunk) + document.removeEventListener('native:upload-submit', this.onUploadSubmit) + } + + get form () { + return this.querySelector('form') + } + + onUploadStart = (event) => { + this.files = [] + + event.detail.result = true + } + + onUploadChunk = ({ detail: { index, name, type, chunk } }) => { + const file = this.files[index] ||= { name, type, chunks: [] } + + file.chunks.push(Uint8Array.from(atob(chunk), (char) => char.charCodeAt(0))) + } + + onUploadSubmit = (event) => { + event.detail.result = this.submit(event.detail.url) + } + + async submit (url) { + const dataTransfer = new DataTransfer() + + this.files.forEach(({ name, type, chunks }) => dataTransfer.items.add(new File(chunks, name, { type }))) + this.files = [] + + const input = this.form.querySelector('[type="file"]') + + input.files = dataTransfer.files + + await convertImagesInInput(input) + + this.form.action = url + this.form.requestSubmit() + } + + onSubmitEnd = (e) => { + if (e.detail.formSubmission?.formElement !== this.form) return + + window.webkit?.messageHandlers?.native?.postMessage({ type: 'upload', success: e.detail.success }) + } +} diff --git a/app/javascript/elements/open_modal_mobile.js b/app/javascript/elements/open_modal_mobile.js index 4096f1f6a..21ae835c6 100644 --- a/app/javascript/elements/open_modal_mobile.js +++ b/app/javascript/elements/open_modal_mobile.js @@ -1,12 +1,17 @@ export default class extends HTMLElement { connectedCallback () { + this.addEventListener('pointerdown', this.onPointerdown) + } + + onPointerdown = (event) => { if (this.dataset.disabled === 'true') return + if (/Hotwire Native/.test(navigator.userAgent)) return if (!this.isMobile()) return if (window.innerWidth >= 768) return - this.querySelectorAll('a[data-turbo-frame]').forEach((link) => { - link.dataset.turboFrame = '_top' - }) + const link = event.target.closest('a[data-turbo-frame]') + + if (link) link.dataset.turboFrame = '_top' } isMobile () { diff --git a/app/javascript/elements/scroll_fade.js b/app/javascript/elements/scroll_fade.js index 20c4906a5..0d34de9a4 100644 --- a/app/javascript/elements/scroll_fade.js +++ b/app/javascript/elements/scroll_fade.js @@ -1,5 +1,7 @@ export default class extends HTMLElement { connectedCallback () { + this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}` + this.addEventListener('scroll', this.updateFade, { passive: true }) window.addEventListener('resize', this.updateFade) diff --git a/app/javascript/elements/search_input.js b/app/javascript/elements/search_input.js index e07e6a5cc..7a5fa2132 100644 --- a/app/javascript/elements/search_input.js +++ b/app/javascript/elements/search_input.js @@ -1,20 +1,29 @@ export default class extends HTMLElement { connectedCallback () { - this.button.addEventListener('click', (event) => { - if (!this.input.value && document.activeElement !== this.input) { - event.preventDefault() - - this.input.focus() - } - }) + this.addEventListener('click', this.onClick) document.addEventListener('turbo:before-cache', this.onBeforeCache) + + if (this.input?.value) { + this.input.focus() + this.input.setSelectionRange(this.input.value.length, this.input.value.length) + } } disconnectedCallback () { document.removeEventListener('turbo:before-cache', this.onBeforeCache) } + onClick = (event) => { + if (!event.target.closest('button')) return + + if (!this.input.value && document.activeElement !== this.input) { + event.preventDefault() + + this.input.focus() + } + } + onBeforeCache = () => { this.input.value = this.input.getAttribute('value') || '' } diff --git a/app/javascript/elements/set_timezone.js b/app/javascript/elements/set_timezone.js index ab3e16bc2..d1370e52c 100644 --- a/app/javascript/elements/set_timezone.js +++ b/app/javascript/elements/set_timezone.js @@ -1,5 +1,7 @@ export default class extends HTMLElement { connectedCallback () { + this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}` + if (this.dataset.inputId) { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js index aa60b6fbd..845a6b6cf 100644 --- a/app/javascript/elements/signature_form.js +++ b/app/javascript/elements/signature_form.js @@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement { static [target.static] = ['canvas', 'input', 'clear', 'button'] async connectedCallback () { + this.lockSwipe(true) + const { default: SignaturePad } = await import('signature_pad') this.setCanvasSize() @@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement { } disconnectedCallback () { + this.lockSwipe(false) + if (this.resizeObserver) { this.resizeObserver.disconnect() } } + lockSwipe (locked) { + window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' }) + } + setCanvasSize () { const scale = 3 diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js index e936c0bdd..db83b5f35 100644 --- a/app/javascript/elements/submit_form.js +++ b/app/javascript/elements/submit_form.js @@ -1,5 +1,19 @@ export default class extends HTMLElement { connectedCallback () { + if (this.lastElementChild) { + this.init() + } else { + queueMicrotask(() => this.init()) + } + } + + disconnectedCallback () { + if (this.interval) { + clearInterval(this.interval) + } + } + + init () { const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form if (this.dataset.interval) { @@ -24,10 +38,4 @@ export default class extends HTMLElement { form.requestSubmit() } } - - disconnectedCallback () { - if (this.interval) { - clearInterval(this.interval) - } - } } diff --git a/app/javascript/elements/toggle_classes.js b/app/javascript/elements/toggle_classes.js index 332ac84ff..c9599dafb 100644 --- a/app/javascript/elements/toggle_classes.js +++ b/app/javascript/elements/toggle_classes.js @@ -1,19 +1,23 @@ export default class extends HTMLElement { connectedCallback () { + this.addEventListener('click', this.onClick) + } + + onClick = (event) => { const button = this.querySelector('a, button, label') + if (!button?.contains(event.target)) return + const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button - button.addEventListener('click', () => { - this.dataset.classes.split(' ').forEach((cls) => { - if (this.dataset.action === 'remove') { - target.classList.remove(cls) - } else if (this.dataset.action === 'add') { - target.classList.add(cls) - } else { - target.classList.toggle(cls) - } - }) + this.dataset.classes.split(' ').forEach((cls) => { + if (this.dataset.action === 'remove') { + target.classList.remove(cls) + } else if (this.dataset.action === 'add') { + target.classList.add(cls) + } else { + target.classList.toggle(cls) + } }) } } diff --git a/app/javascript/elements/toggle_on_submit.js b/app/javascript/elements/toggle_on_submit.js index 8cbc213f9..f14fe4648 100644 --- a/app/javascript/elements/toggle_on_submit.js +++ b/app/javascript/elements/toggle_on_submit.js @@ -1,27 +1,21 @@ export default class extends HTMLElement { connectedCallback () { - document.addEventListener('turbo:submit-end', this.onSubmitEnd) - this.form.addEventListener('submit', this.onSubmit) + this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse) } disconnectedCallback () { - document.removeEventListener('turbo:submit-end', this.onSubmitEnd) - this.form.removeEventListener('submit', this.onSubmit) + this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse) } onSubmit = () => { this.element.classList.add('invisible') } - onSubmitEnd = (event) => { - if (event.target === this.form) { - const resp = event.detail?.formSubmission?.result?.fetchResponse?.response - - if (resp?.status / 100 === 2) { - this.element.classList.remove('invisible') - } + onFetchResponse = (event) => { + if (event.detail.fetchResponse.succeeded) { + this.element.classList.remove('invisible') } } diff --git a/app/javascript/form.js b/app/javascript/form.js index ab9535caa..050e87e03 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -1,3 +1,5 @@ +import './native' + import { createApp, reactive } from 'vue' import Form from './submission_form/form' @@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons' import PageContainer from './elements/page_container' import SubmitForm from './elements/submit_form' import ModalButton from './elements/modal_button' +import NativeAction from './elements/native_action' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) @@ -18,6 +21,7 @@ safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('page-container', PageContainer) safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('modal-button', ModalButton) +safeRegisterElement('native-action', NativeAction) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') @@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement { optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters), schema: JSON.parse(this.dataset.schema), canSendEmail: this.dataset.canSendEmail === 'true', - previousSignatureValue: this.dataset.previousSignatureValue, + prefillSignature: this.dataset.prefillSignature ? JSON.parse(this.dataset.prefillSignature) : null, + prefillInitials: this.dataset.prefillInitials ? JSON.parse(this.dataset.prefillInitials) : null, + currentUser: this.dataset.currentUser ? JSON.parse(this.dataset.currentUser) : null, goToLast: this.dataset.goToLast === 'true', isDemo: this.dataset.isDemo === 'true', attribution: this.dataset.attribution !== 'false', @@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement { language: this.dataset.language, paymentProvider: this.dataset.paymentProvider, dryRun: this.dataset.dryRun === 'true', + viewToken: this.dataset.viewToken, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, withSignatureId: this.dataset.withSignatureId === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true', withConfetti: this.dataset.withConfetti === 'true', + nativePlatform: this.dataset.nativePlatform, withFieldLabels: this.dataset.withFieldLabels !== 'false', withDisclosure: this.dataset.withDisclosure === 'true', reuseSignature: this.dataset.reuseSignature !== 'false', diff --git a/app/javascript/lib/turbo_instant_click.js b/app/javascript/lib/turbo_instant_click.js deleted file mode 100644 index d72d4ce84..000000000 --- a/app/javascript/lib/turbo_instant_click.js +++ /dev/null @@ -1,98 +0,0 @@ -const requestCache = new Map() -const cacheTtl = 10 * 1000 - -function isPreloadable (linkElement) { - const href = linkElement.getAttribute('href') - - if (!href || href === '#' || linkElement.dataset.turbo === 'false' || linkElement.dataset.prefetch === 'false') { - return - } - - if (linkElement.origin !== document.location.origin) { - return - } - - if (!['http:', 'https:'].includes(linkElement.protocol)) { - return - } - - if (linkElement.pathname + linkElement.search === document.location.pathname + document.location.search) { - return - } - - if (linkElement.dataset.turboMethod && linkElement.dataset.turboMethod !== 'get') { - return - } - - return true -} - -function mouseoverListener (event) { - let linkElement - - if (event.target.tagName === 'A') { - linkElement = event.target - } else { - linkElement = event.target.closest('a') - } - - if (!linkElement) { - return - } - - if (!isPreloadable(linkElement)) { - return - } - - const url = linkElement.getAttribute('href') - const loc = new URL(url, location.protocol + '//' + location.host) - const absoluteUrl = loc.toString() - - const cached = requestCache.get(absoluteUrl) - - if (cached && cached.ttl > new Date()) { - return - } - - const requestOptions = { - credentials: 'same-origin', - headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true', 'X-Turbo': 'true' }, - method: 'GET', - redirect: 'follow' - } - - if (linkElement.dataset.turboFrame && linkElement.dataset.turboFrame !== '_top') { - requestOptions.headers['Turbo-Frame'] = linkElement.dataset.turboFrame - } else if (linkElement.dataset.turboFrame !== '_top') { - const turboFrame = linkElement.closest('turbo-frame') - - if (turboFrame) { - requestOptions.headers['Turbo-Frame'] = turboFrame.id - } - } - - requestCache.set(absoluteUrl, { request: fetch(absoluteUrl, requestOptions), ttl: new Date(new Date().getTime() + cacheTtl) }) -} - -function turboBeforeFetchRequest (event) { - if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'GET') { - const cached = requestCache.get(event.detail.url.toString()) - - if (cached && cached.ttl > new Date()) { - event.detail.response = cached.request - } - } - - requestCache.clear() -} - -function start () { - if (!window.turboInstantClickEnabled) { - document.addEventListener('turbo:before-fetch-request', turboBeforeFetchRequest) - document.addEventListener('mouseover', mouseoverListener, { capture: true, passive: true }) - } - - window.turboInstantClickEnabled = true -} - -export { start } diff --git a/app/javascript/native.js b/app/javascript/native.js new file mode 100644 index 000000000..7de1f76a1 --- /dev/null +++ b/app/javascript/native.js @@ -0,0 +1,208 @@ +const ICONS = { + ios: { + link: 'link', + archive: 'archivebox', + folder: 'folder', + trash: 'trash', + rotate: 'arrow.counterclockwise', + copy: 'doc.on.doc', + pencil: 'pencil', + logs: 'list.bullet.rectangle', + external_link: 'arrow.up.right.square', + download: 'arrow.down.circle', + mail_forward: 'paperplane', + send: 'paperplane', + writing: 'signature', + adjustments_horizontal: 'slider.horizontal.3', + file_text: 'doc.text', + files: 'doc.on.doc', + plus: 'plus', + eye: 'eye', + check: 'checkmark', + code: 'chevron.left.forwardslash.chevron.right', + history: 'clock.arrow.circlepath', + user: 'person', + users: 'person.2', + users_plus: 'person.badge.plus', + photo: 'photo', + settings: 'gearshape', + logout: 'rectangle.portrait.and.arrow.right', + upload: 'arrow.up.doc', + scan: 'doc.viewfinder', + pencil_plus: 'square.and.pencil', + arrow_sort: 'arrow.up.arrow.down', + sort_descending_numbers: 'calendar', + sort_descending_small_big: 'clock.arrow.circlepath', + sort_ascending_letters: 'textformat.abc', + calendar: 'calendar', + calendar_check: 'calendar.badge.checkmark', + info_circle: 'info.circle', + list: 'list.bullet', + clock: 'clock', + circle_check: 'checkmark.circle' + } +} + +const platform = navigator.userAgent.includes('Hotwire Native Android') ? 'android' : 'ios' + +export function nativeIcon (name) { + return ICONS[platform]?.[name] || name +} + +const handlers = window.webkit?.messageHandlers +const isNativeLoaded = window.isNativeLoaded + +if (handlers?.native && window === window.top && !isNativeLoaded) { + window.isNativeLoaded = true + + handlers.native.postMessage({ type: 'reset' }) + + const reportPathConfiguration = () => { + const element = document.getElementById('native_path_configuration') + + if (!element) return + + const config = JSON.parse(element.textContent) + + config.rules.forEach(({ properties }) => { + if (properties.native_actions) properties.native_actions = properties.native_actions.map(nativeIcon) + }) + + handlers.native.postMessage({ type: 'path-configuration', json: JSON.stringify(config) }) + } + + document.addEventListener('turbo:load', reportPathConfiguration) + + document.addEventListener('turbo:before-cache', () => { + document.getElementById('native_path_configuration')?.remove() + }) + + reportPathConfiguration() + + const reportFlash = (element) => { + if (!element) return + + if (element.dataset.notice) handlers.flash.postMessage({ style: 'notice', message: element.dataset.notice }) + if (element.dataset.alert) handlers.flash.postMessage({ style: 'alert', message: element.dataset.alert }) + + element.remove() + } + + const reportPageFlash = () => reportFlash(document.getElementById('native_flash')) + + new MutationObserver(reportPageFlash).observe(document.documentElement, { childList: true, subtree: true }) + + document.addEventListener('turbo:load', reportPageFlash) + + document.addEventListener('native:flash', (event) => { + reportFlash(new DOMParser().parseFromString(event.detail.html, 'text/html').getElementById('native_flash')) + }) + + reportPageFlash() + + document.addEventListener('click', (event) => { + const link = event.target.closest('a[href]') + + if (!link) return + + const action = link.closest('native-action, native-menu-action') + const title = link.dataset.nativeTitle || link.getAttribute('aria-label') || link.closest('[data-native-title]')?.dataset.nativeTitle || action?.dataset.label || action?.textContent.replace(/\s+/g, ' ').trim() + + if (!title) return + + const actions = link.closest('[data-native-actions]')?.dataset.nativeActions + + handlers.native.postMessage({ type: 'title', url: link.href, title, actions: actions ? actions.split(',').map(nativeIcon) : null }) + }, true) + + document.addEventListener('click', (event) => { + if (document.querySelector('native-modal')) return + + const link = event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]') + + if (!link?.href) return + + event.preventDefault() + event.stopPropagation() + + handlers.modal.postMessage({ action: 'open', url: link.href, modal: link.closest('[data-native-modal]')?.dataset.nativeModal || '' }) + }, true) + + let isTouchPrefetch = false + + document.addEventListener('touchstart', (event) => { + isTouchPrefetch = true + event.target.closest('a[href]')?.dispatchEvent(new MouseEvent('mouseenter')) + isTouchPrefetch = false + }, { passive: true }) + + document.addEventListener('turbo:before-prefetch', (event) => { + if (!isTouchPrefetch || event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')) { + event.preventDefault() + } + }) + + document.addEventListener('native:action', (event) => { + document.querySelector(`[data-native-id="${event.detail.id}"]`)?.click() + }) + + document.addEventListener('native:title-bottom', (event) => { + const rect = document.querySelector('h1, h2')?.getBoundingClientRect() + + event.detail.result = rect?.height ? rect.bottom - document.documentElement.getBoundingClientRect().top : null + }) +} + +if (handlers?.native && window.Turbo && !isNativeLoaded) { + const staleSnapshots = new Set() + let isRestoring = false + + window.Turbo.session.view.clearSnapshotCache = function () { + this.snapshotCache.keys.forEach((key) => staleSnapshots.add(key)) + } + + window.Turbo.session.navigator.linkPrefetchingIsEnabledForLocation = () => !document.querySelector('native-modal') + + document.addEventListener('turbo:submit-end', (e) => { + if (e.detail.success && !e.detail.formSubmission.isSafe) { + handlers.native.postMessage({ type: 'stale' }) + } + }) + + document.addEventListener('native:pull-to-refresh', (event) => { + const { session } = window.Turbo + const url = new URL(document.baseURI) + + url.searchParams.delete('limit') + + session.history.replace(url, session.history.restorationIdentifier) + session.view.lastRenderedLocation = url + + event.detail.result = new Promise((resolve) => { + document.addEventListener('turbo:render', () => resolve(true), { once: true }) + document.addEventListener('turbo:fetch-request-error', () => resolve(true), { once: true }) + + session.refresh(document.baseURI) + }) + }) + + document.addEventListener('native:refresh', () => { + window.Turbo.session.refresh(document.baseURI) + }) + + document.addEventListener('native:turbo-stream', (e) => { + if (!e.detail.body?.trimStart().startsWith(' { + isRestoring = e.detail.action === 'restore' + }) + + document.addEventListener('turbo:load', () => { + if (staleSnapshots.delete(window.location.href.split('#')[0]) && isRestoring && window.location.pathname !== '/search') { + setTimeout(() => window.Turbo.session.refresh(document.baseURI)) + } + }) +} diff --git a/app/javascript/submission_form/accessibility_areas.vue b/app/javascript/submission_form/accessibility_areas.vue index 8f05d4c93..6f593f7ff 100644 --- a/app/javascript/submission_form/accessibility_areas.vue +++ b/app/javascript/submission_form/accessibility_areas.vue @@ -125,6 +125,11 @@ export default { required: false, default: () => [] }, + fieldsUuidIndex: { + type: Object, + required: false, + default: () => ({}) + }, values: { type: Object, required: false, @@ -195,7 +200,7 @@ export default { }, isMobileContainer: FieldAreas.computed.isMobileContainer, isInlineSize: FieldAreas.computed.isInlineSize, - fieldsUuidIndex () { + formulaFieldsUuidIndex () { return this.formulaFields.reduce((acc, field) => { acc[field.uuid] = field @@ -289,6 +294,7 @@ export default { methods: { normalizeFormula: FormulaAreas.methods.normalizeFormula, calculateFormula: FormulaAreas.methods.calculateFormula, + dateFormulaValue: FormulaAreas.methods.dateFormulaValue, evalTextFormula: FormulaAreas.methods.evalTextFormula, scrollInContainer: FieldAreas.methods.scrollInContainer, scrollIntoArea: FieldAreas.methods.scrollIntoArea, diff --git a/app/javascript/submission_form/calculator.js b/app/javascript/submission_form/calculator.js index 55cc185f2..a1fc446ee 100644 --- a/app/javascript/submission_form/calculator.js +++ b/app/javascript/submission_form/calculator.js @@ -633,6 +633,32 @@ defineFunction('contains', { return String(v[0]).indexOf(String(v[1])) !== -1 } }) +defineFunction('today', { + min: 0, + max: 0, + call: () => { + const now = new Date() + return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86400000 + } +}) +defineFunction('datedif', { + min: 3, + max: 3, + call: (v) => { + if (v[0] == null || v[1] == null) return null + const start = toNumber(v[0]) + const end = toNumber(v[1]) + const unit = String(v[2]).toLowerCase() + if (unit === 'd') return end - start + const from = new Date(Math.min(start, end) * 86400000) + const to = new Date(Math.max(start, end) * 86400000) + const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + to.getUTCMonth() - from.getUTCMonth() - (to.getUTCDate() < from.getUTCDate() ? 1 : 0) + const sign = end < start ? -1 : 1 + if (unit === 'm') return sign * months + if (unit === 'y') return sign * Math.floor(months / 12) + return null + } +}) const OP_CLASSES = { add: Addition, diff --git a/app/javascript/submission_form/completed.vue b/app/javascript/submission_form/completed.vue index 0cfe3733e..d44f9d2e6 100644 --- a/app/javascript/submission_form/completed.vue +++ b/app/javascript/submission_form/completed.vue @@ -56,7 +56,7 @@ + +
-
+
@@ -690,7 +924,7 @@ :revisions="revisions" :locale="locale" @close="isRevisionsModalOpen = false" - @apply="onRevisionApply" + @apply="onRevisionApply($event)" /> 0 + }, + withDocumentsDrawer () { + return this.withMobileDrawers && this.withDocumentsList && (this.isBreakpointLg || this.isMobile) + }, + withFieldsDrawer () { + return this.withMobileDrawers && this.withFieldsList && this.editable && this.isBreakpointMd + }, isMobile () { const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent) @@ -1379,6 +1648,7 @@ export default { }) document.addEventListener('keyup', this.onKeyUp) + document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) window.addEventListener('keydown', this.onKeyDown) window.addEventListener('resize', this.onWindowResize) @@ -1402,6 +1672,7 @@ export default { }, unmounted () { document.removeEventListener('keyup', this.onKeyUp) + document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent) window.removeEventListener('keydown', this.onKeyDown) window.removeEventListener('resize', this.onWindowResize) @@ -1528,10 +1799,28 @@ export default { }, calculateFormula (field) { const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => { + if (this.fieldsUuidIndex[uuid]?.type === 'date') { + return this.dateFormulaValue(this.fieldsUuidIndex[uuid].default_value) + } + return this.fieldsUuidIndex[uuid]?.default_value || 0.0 }) - return this.math.evaluate(transformedFormula.toLowerCase()) + const value = this.math.evaluate(transformedFormula.toLowerCase()) + + if (field.type === 'date') { + return Number.isFinite(value) ? new Date(Math.floor(value) * 86400000).toISOString().slice(0, 10) : '' + } + + return value + }, + dateFormulaValue (value) { + if (!value) return 'null' + if (value === '{{date}}') return 'today()' + + const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number) + + return Date.UTC(year, month - 1, day) / 86400000 }, evalTextFormula (field, depth = 0) { if (depth > 10) return '' @@ -1665,6 +1954,10 @@ export default { this.debouncedSave() }, download () { + const download = window.webkit?.messageHandlers?.download + + if (this.nativePlatform && download) return download.postMessage({ src: `/templates/${this.template.id}/documents` }) + this.isDownloading = true this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => { @@ -1937,6 +2230,10 @@ export default { this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`) }, openRevisionsModal () { + if (this.nativePlatform && window.webkit?.messageHandlers?.modal) { + return window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'revisions', detent: 'medium' }) + } + this.closeDropdown() this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`) @@ -1949,10 +2246,20 @@ export default { this.loadRevisionsPromise = null }) }, + openDocumentsEditor (item) { + if (this.nativePlatform && window.webkit?.messageHandlers?.modal) { + window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'documents', viewport: 'fixed', uuid: item.attachment_uuid }) + } else { + this.editModalDocumentUuid = item.attachment_uuid + } + }, + onApplyRevisionEvent (e) { + this.onRevisionApply(e.detail) + }, onRevisionApply (revision) { this.beforeRevisionSnapshot = { - template: JSON.parse(JSON.stringify(this.template)), - dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)), + template: this.beforeRevisionSnapshot?.template || JSON.parse(JSON.stringify(this.template)), + dynamicDocuments: this.beforeRevisionSnapshot?.dynamicDocuments || JSON.parse(JSON.stringify(this.dynamicDocuments)), revision } @@ -2011,6 +2318,10 @@ export default { this.save() }, addField (type, area = null) { + if (this.isVerificationFieldAdded(type)) { + return + } + const field = { name: '', uuid: v4(), @@ -2054,12 +2365,26 @@ export default { this.save() }, + isVerificationFieldAdded (type) { + const addedField = ['kba', 'verification'].includes(type) && + this.template.fields.find((f) => ['kba', 'verification'].includes(f.type) && f.submitter_uuid === this.selectedSubmitter.uuid) + + if (addedField) { + alert(this.t('field_has_already_been_added_for_party').replace('{field}', this.fieldNames[addedField.type]).replace('{party}', this.selectedSubmitter.name)) + } + + return !!addedField + }, startFieldDraw ({ name, type }) { const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name) if (existingField) { this.drawField = existingField } else { + if (this.isVerificationFieldAdded(type)) { + return + } + const field = { name: name || '', uuid: v4(), @@ -2120,11 +2445,20 @@ export default { }, onWindowResize (e) { const breakpointLg = 1024 + const breakpointMd = 768 + const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth - this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg + this.isBreakpointLg = width < breakpointLg + this.isBreakpointMd = width < breakpointMd }, onPagesWheel (event) { - if (!event.ctrlKey && !event.metaKey) return + const isFastScroll = Math.abs(event.deltaY) > 1 + + if ((!event.ctrlKey && !event.metaKey) || (isFastScroll && event.timeStamp - this.lastFastScrollAt < 150)) { + if (isFastScroll) this.lastFastScrollAt = event.timeStamp + + return + } event.preventDefault() @@ -2155,7 +2489,73 @@ export default { scrollIntoDocument (item) { const ref = this.documentRefs.find((e) => e.document.uuid === item.attachment_uuid) - ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + ref?.$el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + postNativeEvent (name, detail, dismiss = false) { + if (this.nativePlatform) { + window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name, detail: JSON.stringify(detail), dismiss: dismiss ? 'true' : 'false' }) + } + }, + onDrawerScrollTo (item) { + if (this.nativeView) { + this.postNativeEvent('template-builder:scroll-to', { attachment_uuid: item.attachment_uuid }, true) + } else { + this.isDocumentsDrawerOpen = false + this.scrollIntoDocument(item) + } + }, + onDrawerSetDraw ({ field, option }) { + if (this.nativeView) { + this.postNativeEvent('template-builder:draw-field', { uuid: field.uuid, option }, true) + } else { + this.isFieldsDrawerOpen = false + this.drawField = field + this.drawOption = option + } + }, + onDrawerSetDrawType (type) { + if (this.nativeView) { + this.postNativeEvent('template-builder:draw-field', { type }, true) + } else { + this.isFieldsDrawerOpen = false + this.startFieldDraw({ type }) + } + }, + onDrawerSelectSubmitter (submitter) { + this.selectedSubmitter = submitter + + if (this.nativeView) { + this.$nextTick(() => this.postNativeEvent('template-builder:select-submitter', { uuid: submitter.uuid })) + } + }, + onDrawerSetDrawCustomField (field) { + if (this.nativeView) { + this.postNativeEvent('template-builder:draw-field', { custom_field_uuid: field.uuid }, true) + } else { + this.isFieldsDrawerOpen = false + this.drawCustomField = field + this.showDrawField = true + } + }, + onDrawerScrollToArea (area) { + if (this.nativeView) { + const field = this.template.fields.find((f) => (f.areas || []).includes(area)) + + this.postNativeEvent('template-builder:select-field', { uuid: field?.uuid, attachment_uuid: area.attachment_uuid, page: area.page, x: area.x, y: area.y }, true) + } else { + this.isFieldsDrawerOpen = false + this.scrollToArea(area) + } + }, + syncNative () { + const { schema, documents, fields, submitters } = this.template + const detail = JSON.stringify({ schema, documents, fields, submitters, pendingFieldAttachmentUuids: this.pendingFieldAttachmentUuids }) + + if (this.nativeView) { + window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: 'template-builder:sync', detail, dismiss: 'false' }) + } else { + window.webkit?.messageHandlers?.native?.postMessage({ type: 'drawer', op: 'sync', detail }) + } }, clearDrawField () { this.drawField = null @@ -2731,6 +3131,10 @@ export default { return this.dropCustomField(area) } + if (!this.fieldsDragFieldRef.value && this.isVerificationFieldAdded(this.dragField.type)) { + return + } + const field = this.fieldsDragFieldRef.value || { name: '', uuid: v4(), @@ -3091,6 +3495,10 @@ export default { }) } }) + + if (this.nativeView && this.pendingFieldAttachmentUuids.length) { + this.syncNative() + } }, updateName (value) { this.template.name = value @@ -3541,6 +3949,10 @@ export default { this.pushUndo() + if (this.nativePlatform) { + this.syncNative() + } + if (!this.autosave && !force) { return Promise.resolve({}) } diff --git a/app/javascript/template_builder/conditions_modal.vue b/app/javascript/template_builder/conditions_modal.vue index 0b17affc0..6ba5fe8cb 100644 --- a/app/javascript/template_builder/conditions_modal.vue +++ b/app/javascript/template_builder/conditions_modal.vue @@ -1,13 +1,17 @@ - - diff --git a/app/javascript/template_builder/custom_field.vue b/app/javascript/template_builder/custom_field.vue index 213172612..ae3f2fd29 100644 --- a/app/javascript/template_builder/custom_field.vue +++ b/app/javascript/template_builder/custom_field.vue @@ -47,10 +47,11 @@ + @@ -85,11 +94,27 @@ :with-areas="false" :with-copy-to-all-pages="false" :with-condition="false" - @click-formula="isShowFormulaModal = true" - @click-font="isShowFontModal = true" - @click-description="isShowDescriptionModal = true" + @click-formula="openFormulaModal" + @click-font="openFontModal" + @click-description="openDescriptionModal" @save="$emit('save')" /> +
  • + + + {{ t('remove') }} + +
  • -
    +
    @@ -49,14 +46,13 @@ >
    -