initial commit

This commit is contained in:
Alex Turchyn
2023-05-21 17:59:36 +03:00
commit 97c462ead2
159 changed files with 10987 additions and 0 deletions
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
before_action :authenticate_user!
private
def current_account
current_user&.account
end
end
end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Api
class AttachmentsController < ApiBaseController
skip_before_action :authenticate_user!
def create
submission = Submission.find_by!(slug: params[:submission_slug])
blob = ActiveStorage::Blob.find_signed(params[:blob_signed_id])
attachment = ActiveStorage::Attachment.create!(
blob:,
name: params[:name],
record: submission
)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end
end
end
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
module Api
class FlowsController < ApiBaseController
def update
@flow = current_account.flows.find(params[:id])
@flow.update!(flow_params)
render :ok
end
private
def flow_params
params.require(:flow).permit(:name,
schema: [%i[attachment_uuid name]],
fields: [[:uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h attachment_uuid page]] }]])
end
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Api
class FlowsDocumentsController < ApiBaseController
def create
@flow = current_account.flows.find(params[:flow_id])
documents =
params[:blobs].map do |blob|
blob = ActiveStorage::Blob.find_signed(blob[:signed_id])
document = @flow.documents.create!(blob:)
Flows::ProcessDocument.call(document)
end
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class ApplicationController < ActionController::Base
include ActiveStorage::SetCurrent
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
helper_method :button_title,
:current_account
private
def current_account
current_user&.account
end
def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists?
end
def button_title(title = 'Submit', disabled_with = 'Submitting...')
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with: })
end
end
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class DashboardController < ApplicationController
def index
@flows = current_account.flows.active
end
end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class EmailSettingsController < ApplicationController
before_action :load_encrypted_config
def index; end
def create
if @encrypted_config.update(storage_configs)
redirect_to settings_email_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def storage_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
end
end
end
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class FlowsController < ApplicationController
def show
@flow = current_account.flows.preload(documents_attachments: { preview_images_attachments: :blob })
.find(params[:id])
end
def new
@flow = current_account.flows.new
end
def create
@flow = current_account.flows.new(flow_params)
@flow.author = current_user
if @flow.save
redirect_to flow_path(@flow)
else
render turbo_stream: turbo_stream.replace(:modal, template: 'flows/new'), status: :unprocessable_entity
end
end
def destroy
@flow = current_account.flows.find(params[:id])
@flow.update!(deleted_at: Time.current)
redirect_to settings_users_path, notice: 'Flow has been archived.'
end
private
def flow_params
params.require(:flow).permit(:name, :schema)
end
end
@@ -0,0 +1,4 @@
# frozen_string_literal: true
class InvitationsController < Devise::PasswordsController
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
private
def build_resource(_hash = {})
account = Account.new(account_params)
self.resource = account.users.new(user_params)
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password)
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name)
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class SendSubmissionEmailController < ApplicationController
layout 'flow'
skip_before_action :authenticate_user!
def success; end
def create
@submission = if params[:flow_slug]
Submission.joins(:flow).find_by!(email: params[:email], flow: { slug: params[:flow_slug] })
else
Submission.find_by!(slug: params[:submission_slug])
end
SubmissionMailer.copy_to_submitter(@submission).deliver_later!
redirect_to success_send_submission_email_index_path
end
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
class SetupController < ApplicationController
skip_before_action :maybe_redirect_to_setup
skip_before_action :authenticate_user!
before_action :redirect_to_root_if_signed, if: :signed_in?
before_action :ensure_first_user_not_created!
def index
@account = Account.new(account_params)
@user = @account.users.new(user_params)
end
def create
@account = Account.new(account_params)
@user = @account.users.new(user_params)
if @user.save
sign_in(@user)
redirect_to root_path
else
render :index, status: :unprocessable_entity
end
end
private
def user_params
return {} unless params[:user]
params.require(:user).permit(:first_name, :last_name, :email, :password)
end
def account_params
return {} unless params[:account]
params.require(:account).permit(:name)
end
def redirect_to_root_if_signed
redirect_to root_path, notice: 'You are already signed in'
end
def ensure_first_user_not_created!
redirect_to new_user_session_path, notice: 'Please sign in.' if User.exists?
end
end
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class StartFlowController < ApplicationController
layout 'flow'
skip_before_action :authenticate_user!
before_action :load_flow
def show
@submission = @flow.submissions.new
end
def update
@submission = @flow.submissions.find_or_initialize_by(
deleted_at: nil, **submission_params
)
if @submission.completed_at?
redirect_to start_flow_completed_path(@flow.slug, email: submission_params[:email])
else
@submission.assign_attributes(
opened_at: Time.current,
ip: request.remote_ip,
ua: request.user_agent
)
if @submission.save
redirect_to submit_flow_path(@submission.slug)
else
render :show
end
end
end
def completed
@submission = @flow.submissions.find_by(email: params[:email])
end
private
def submission_params
params.require(:submission).permit(:email)
end
def load_flow
slug = params[:slug] || params[:start_flow_slug]
@flow = Flow.find_by!(slug:)
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class StorageSettingsController < ApplicationController
before_action :load_encrypted_config
def index; end
def create
if @encrypted_config.update(storage_configs)
LoadActiveStorageConfigs.reload
redirect_to settings_storage_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::FILES_STORAGE_KEY)
end
def storage_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
e.dig(:value, :configs)&.compact_blank!
end
end
end
+50
View File
@@ -0,0 +1,50 @@
# frozen_string_literal: true
class SubmissionsController < ApplicationController
before_action :load_flow, only: %i[index new create]
def index
@submissions = @flow.submissions.active
end
def show
@submission =
Submission.joins(:flow).where(flow: { account_id: current_account.id })
.preload(flow: { documents_attachments: { preview_images_attachments: :blob } })
.find(params[:id])
end
def new; end
def create
emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
submissions =
emails.map do |email|
submission = @flow.submissions.create!(email:, sent_at: params[:send_email] == '1' ? Time.current : nil)
if params[:send_email] == '1'
SubmissionMailer.invitation_email(submission, message: params[:message]).deliver_later!
end
submission
end
redirect_to flow_submissions_path(@flow), notice: "#{submissions.size} recepients added"
end
def destroy
submission = Submission.joins(:flow).where(flow: { account_id: current_account.id })
.find(params[:id])
submission.update!(deleted_at: Time.current)
redirect_to flow_submissions_path(submission.flow), notice: 'Submission has been archieved'
end
private
def load_flow
@flow = current_account.flows.find(params[:flow_id])
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
def index
submission = Submission.find_by(slug: params[:submission_slug])
Submissions::GenerateResultAttachments.call(submission)
redirect_to submission.archive.url, allow_other_host: true
end
end
+28
View File
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class SubmitFlowController < ApplicationController
layout 'flow'
skip_before_action :authenticate_user!
def show
@submission = Submission.preload(flow: { documents_attachments: { preview_images_attachments: :blob } })
.find_by!(slug: params[:slug])
return redirect_to submit_flow_completed_path(@submission.slug) if @submission.completed_at?
end
def update
submission = Submission.find_by!(slug: params[:slug])
submission.values.merge!(params[:values].to_unsafe_h)
submission.completed_at = Time.current if params[:completed] == 'true'
submission.save
head :ok
end
def completed
@submission = Submission.find_by!(slug: params[:submit_flow_slug])
end
end
+56
View File
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class UsersController < ApplicationController
before_action :load_user, only: %i[edit update destroy]
def index
@users = current_account.users.active.order(id: :desc)
end
def new
@user = current_account.users.new
end
def edit; end
def create
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
user.assign_attributes(user_params)
user.deleted_at = nil
end
@user ||= current_account.users.new(user_params)
if @user.save
UserMailer.invitation_email(@user).deliver_later!
redirect_to settings_users_path, notice: 'User has been invited.'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end
end
def update
if @user.update(user_params.compact_blank)
redirect_to settings_users_path, notice: 'User has been updated.'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
end
end
def destroy
@user.update!(deleted_at: Time.current)
redirect_to settings_users_path, notice: 'User has been removed.'
end
private
def load_user
@user = current_account.users.find(params[:id])
end
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :password)
end
end
+36
View File
@@ -0,0 +1,36 @@
import '@hotwired/turbo-rails'
import { createApp } from 'vue'
import ToggleVisible from './elements/toggle_visible'
import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal'
import FlowArea from './elements/flow_area'
import FlowView from './elements/flow_view'
import Builder from './components/builder'
window.customElements.define('toggle-visible', ToggleVisible)
window.customElements.define('disable-hidden', DisableHidden)
window.customElements.define('turbo-modal', TurboModal)
window.customElements.define('flow-view', FlowView)
window.customElements.define('flow-area', FlowArea)
window.customElements.define('flow-builder', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.app = createApp(Builder, {
dataFlow: this.dataset.flow
})
this.app.mount(this.appElem)
this.appendChild(this.appElem)
}
disconnectedCallback () {
this.app?.unmount()
this.appElem?.remove()
}
})
+32
View File
@@ -0,0 +1,32 @@
@config "../../tailwind.application.config.js";
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
a[href],
input[type='checkbox'],
input[type='submit'],
input[type='image'],
input[type='radio'],
label[for],
select,
button {
cursor: pointer;
}
button .disabled {
display: none;
}
button[disabled] .disabled {
display: initial;
}
button .enabled {
display: initial;
}
button[disabled] .enabled {
display: none;
}
+107
View File
@@ -0,0 +1,107 @@
<template>
<div
class="bg-red-100 absolute opacity-70"
:style="positionStyle"
@mousedown="startDrag"
>
<div
v-if="field"
class="flex items-center justify-center h-full w-full"
>
{{ field?.name || field.type }}
</div>
<span
class="h-2 w-2 right-0 bottom-0 bg-red-900 absolute cursor-nwse-resize"
@mousedown.stop="startResize"
/>
</div>
</template>
<script>
export default {
name: 'FieldArea',
props: {
scale: {
type: Number,
required: false,
default: 1
},
bounds: {
type: Object,
required: false,
default () {
return {
x: 0,
y: 0,
w: 0,
h: 0
}
}
},
field: {
type: Object,
required: false,
default: null
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag'],
data () {
return {
isResize: false,
dragFrom: { x: 0, y: 0 }
}
},
computed: {
positionStyle () {
const { x, y, w, h } = this.bounds
return {
top: this.scale * y + 'px',
left: this.scale * x + 'px',
width: this.scale * w + 'px',
height: this.scale * h + 'px'
}
}
},
methods: {
resize (e) {
this.bounds.w = e.layerX / this.scale - this.bounds.x
this.bounds.h = e.layerY / this.scale - this.bounds.y
},
drag (e) {
if (e.toElement.id === 'mask') {
this.bounds.x = (e.layerX - this.dragFrom.x) / this.scale
this.bounds.y = (e.layerY - this.dragFrom.y) / this.scale
}
},
startDrag (e) {
const rect = e.target.getBoundingClientRect()
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
document.addEventListener('mousemove', this.drag)
document.addEventListener('mouseup', this.stopDrag)
this.$emit('start-drag')
},
stopDrag () {
document.removeEventListener('mousemove', this.drag)
document.removeEventListener('mouseup', this.stopDrag)
this.$emit('stop-drag')
},
startResize () {
document.addEventListener('mousemove', this.resize)
document.addEventListener('mouseup', this.stopResize)
this.$emit('start-resize')
},
stopResize () {
document.removeEventListener('mousemove', this.resize)
document.removeEventListener('mouseup', this.stopResize)
this.$emit('stop-resize')
}
}
}
</script>
+154
View File
@@ -0,0 +1,154 @@
<template>
<div
class="flex"
style="max-height: calc(100vh - 24px)"
>
<div
class="overflow-auto w-full"
style="max-width: 280px"
>
Show documents preview (not pages but documents)
Allow to edit name
Allow to reorder
{{ flow.schema }}
<Upload
:flow-id="flow.id"
@success="updateFromUpload"
/>
<button
class="bg-green-300"
@click="save"
>
Save changes
</button>
<a
:href="`/flows/${flow.id}/submissions`"
>
Add Recepients
</a>
</div>
<div class="w-full overflow-auto">
<Document
v-for="document in sortedDocuments"
:key="document.uuid"
:areas-index="fieldAreasIndex[document.uuid]"
:document="document"
:is-draw="!!drawField"
@draw="onDraw"
/>
</div>
<div
class="w-full relative"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
style="max-width: 280px"
>
<div
v-if="drawField"
class="sticky inset-0 bg-white h-full"
>
Draw {{ drawField.name }} field on the page
<button @click="drawField = false">
Cancel
</button>
</div>
<div>
FIelds
<Fields
v-model:fields="flow.fields"
@set-draw="drawField = $event"
/>
</div>
</div>
</div>
</template>
<script>
import Upload from './upload'
import Fields from './fields'
import Document from './document'
export default {
name: 'FlowBuilder',
components: {
Upload,
Document,
Fields
},
props: {
dataFlow: {
type: String,
default: '{}'
}
},
data () {
return {
drawField: null,
flow: {
name: '',
schema: [],
documents: [],
fields: []
}
}
},
computed: {
fieldAreasIndex () {
const areas = {}
this.flow.fields.forEach((f) => {
(f.areas || []).forEach((a) => {
areas[a.attachment_uuid] ||= {}
const acc = (areas[a.attachment_uuid][a.page] ||= [])
acc.push({ area: a, field: f })
})
})
return areas
},
sortedDocuments () {
return this.flow.schema.map((item) => {
return this.flow.documents.find(doc => doc.uuid === item.attachment_uuid)
})
}
},
mounted () {
this.flow = JSON.parse(this.dataFlow)
document.addEventListener('keyup', this.disableDrawOnEsc)
},
unmounted () {
document.removeEventListener('keyup', this.disableDrawOnEsc)
},
methods: {
disableDrawOnEsc (e) {
if (e.code === 'Escape') {
this.drawField = null
}
},
onDraw (area) {
this.drawField.areas ||= []
this.drawField.areas.push(area)
this.drawField = null
},
updateFromUpload ({ schema, documents }) {
this.flow.schema.push(...schema)
this.flow.documents.push(...documents)
this.save()
},
save () {
return fetch(`/api/flows/${this.flow.id}`, {
method: 'PUT',
body: JSON.stringify({ flow: this.flow }),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => {
console.log(resp)
})
}
}
}
</script>
+44
View File
@@ -0,0 +1,44 @@
<template>
<Page
v-for="(image, index) in sortedPreviewImages"
:key="image.id"
:number="index"
:areas="areasIndex[index]"
:is-draw="isDraw"
:class="{ 'cursor-crosshair': isDraw }"
:image="image"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
/>
</template>
<script>
import Page from './page'
export default {
name: 'FlowDocument',
components: {
Page
},
props: {
document: {
type: Object,
required: true
},
areasIndex: {
type: Object,
required: false,
default: () => ({})
},
isDraw: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw'],
computed: {
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
}
}
}
</script>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div>
{{ field.type }}
<div v-if="field.type !== 'signature'">
<label>Name</label>
<input
v-model="field.name"
type="text"
required
>
</div>
<div>
<div
v-for="(option, index) in field.options"
:key="index"
class="flex"
>
<input
v-model="field.options[index]"
type="text"
required
>
<button @click="field.options.splice(index, 1)">
Remove
</button>
</div>
<button @click="field.options.push('')">
Add option
</button>
</div>
<div>
<div
v-for="(area, index) in areas"
:key="index"
>
{{ area }}
<button @click="removeArea(area)">
&times;
</button>
</div>
<button
class="block"
@click="$emit('set-draw', field)"
>
Draw area
</button>
</div>
<div>
<input
:id="`field_required_${field.uuid}`"
v-model="field.required"
type="checkbox"
required
>
<label :for="`field_required_${field.uuid}`">Required</label>
</div>
</div>
</template>
<script>
export default {
name: 'FlowField',
props: {
field: {
type: Object,
required: true
}
},
emits: ['set-draw'],
computed: {
areas () {
return this.field.areas || []
}
},
methods: {
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
}
}
}
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="space-y-2">
<Field
v-for="field in fields"
:key="field.uuid"
class="border"
:field="field"
@set-draw="$emit('set-draw', $event)"
/>
</div>
<button
v-for="item in fieldTypes"
:key="item.type"
class="block w-full"
@click="addField(item.value)"
>
Add {{ item.label }}
</button>
</template>
<script>
import Field from './field'
import { v4 } from 'uuid'
export default {
name: 'FlowFields',
components: {
Field
},
props: {
fields: {
type: Array,
required: true
}
},
emits: ['set-draw'],
computed: {
fieldTypes () {
return [
{ label: 'Text', value: 'text' },
{ label: 'Signature', value: 'signature' },
{ label: 'Date', value: 'date' },
{ label: 'Image', value: 'image' },
{ label: 'Attachment', value: 'attachment' },
{ label: 'Select', value: 'select' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Radio Group', value: 'radio' }
]
}
},
methods: {
addField (type) {
const field = {
name: type === 'signature' ? 'Signature' : '',
uuid: v4(),
required: true,
type
}
if (['select', 'checkbox', 'radio'].includes(type)) {
field.options = ['']
}
this.fields.push(field)
}
}
}
</script>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="relative">
<img
ref="image"
:src="image.url"
:width="width"
:height="height"
loading="lazy"
>
<div
class="top-0 bottom-0 left-0 right-0 absolute"
>
<FieldArea
v-for="(item, i) in areas"
:key="i"
:scale="scale"
:bounds="item.area"
:field="item.field"
@start-resize="showMask = true"
@stop-resize="showMask = false"
@start-drag="showMask = true"
@stop-drag="showMask = false"
/>
<FieldArea
v-if="newArea"
:scale="scale"
:bounds="newArea"
/>
</div>
<div
v-show="isDraw || showMask"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
/>
</div>
</template>
<script>
import FieldArea from './area'
export default {
name: 'FlowPage',
components: {
FieldArea
},
props: {
image: {
type: Object,
required: true
},
areas: {
type: Array,
required: false,
default: () => []
},
isDraw: {
type: Boolean,
required: false,
default: false
},
number: {
type: Number,
required: true
}
},
emits: ['draw'],
data () {
return {
scale: 1,
showMask: false,
newArea: null
}
},
computed: {
width () {
return this.image.metadata.width
},
height () {
return this.image.metadata.height
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.onResize)
this.resizeObserver.observe(this.$refs.image)
},
beforeUnmount () {
this.resizeObserver.unobserve(this.$refs.image)
},
methods: {
onResize () {
this.scale = this.$refs.image.clientWidth / this.image.metadata.width
},
onPointerdown (e) {
if (this.isDraw) {
this.newArea = {
initialX: e.layerX / this.scale,
initialY: e.layerY / this.scale,
x: e.layerX / this.scale,
y: e.layerY / this.scale,
w: 0,
h: 0
}
}
},
onPointermove (e) {
if (this.newArea) {
const dx = e.layerX / this.scale - this.newArea.initialX
const dy = e.layerY / this.scale - this.newArea.initialY
if (dx > 0) {
this.newArea.x = this.newArea.initialX
} else {
this.newArea.x = e.layerX / this.scale
}
if (dy > 0) {
this.newArea.y = this.newArea.initialY
} else {
this.newArea.y = e.layerY / this.scale
}
this.newArea.w = Math.abs(dx)
this.newArea.h = Math.abs(dy)
}
},
onPointerup (e) {
if (this.isDraw && this.newArea) {
this.$emit('draw', {
x: this.newArea.x,
y: this.newArea.y,
w: Math.max(this.newArea.w, 50),
h: Math.max(this.newArea.h, 40),
page: this.number
})
}
this.newArea = null
}
}
}
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<input
ref="input"
type="file"
multiple
@change="upload"
>
</template>
<script>
import { DirectUpload } from '@rails/activestorage'
export default {
name: 'DocumentsUpload',
props: {
flowId: {
type: [Number, String],
required: true
}
},
emits: ['success'],
methods: {
async upload () {
const blobs = await Promise.all(
Array.from(this.$refs.input.files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.$refs.input
)
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
} else {
return resolve(blob)
}
})
}).catch((error) => {
console.error(error)
})
})
)
fetch(`/api/flows/${this.flowId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
}).then(resp => resp.json()).then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
}
}
}
</script>
+38
View File
@@ -0,0 +1,38 @@
export default class extends HTMLElement {
static observedAttributes = ['class']
connectedCallback () {
this.trigger()
}
attributeChangedCallback (attributeName, oldValue, newValue) {
if (attributeName === 'class' && oldValue !== newValue) {
this.trigger()
}
}
trigger () {
const hasHiddenClass = this.classList.contains('hidden')
const elements = this.querySelectorAll('input, textarea, select')
elements.forEach((element) => {
if (hasHiddenClass) {
element.disabled = true
if (!element.dataset.wasRequired) {
element.dataset.wasRequired = element.required
}
element.required = false
} else {
element.disabled = false
if (element.dataset.wasRequired) {
element.required = element.dataset.wasRequired === 'true'
delete element.dataset.wasRequired
}
}
})
}
}
+76
View File
@@ -0,0 +1,76 @@
import { DirectUpload } from '@rails/activestorage'
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'loading',
'input'
]
connectedCallback () {
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragover', (e) => e.preventDefault())
}
onDrop (e) {
e.preventDefault()
this.uploadFiles(e.dataTransfer.files)
}
onSelectFiles (e) {
e.preventDefault()
this.uploadFiles(this.input.files).then(() => {
this.input.value = ''
})
}
async uploadFiles (files) {
const blobs = await Promise.all(
Array.from(files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.input
)
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
} else {
return resolve(blob)
}
})
}).catch((error) => {
console.error(error)
})
})
)
await Promise.all(
blobs.map((blob) => {
return fetch('/api/attachments', {
method: 'POST',
body: JSON.stringify({
name: 'attachments',
blob_signed_id: blob.signed_id,
submission_slug: this.dataset.submissionSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(resp => resp.json()).then((data) => {
return data
})
})).then((result) => {
result.forEach((attachment) => {
this.dispatchEvent(new CustomEvent('upload', { detail: attachment }))
})
})
}
}))
+16
View File
@@ -0,0 +1,16 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { targets, targetable } from '@github/catalyst/lib/targetable'
export default actionable(targetable(class extends HTMLElement {
static [targets.static] = [
'items'
]
add (e) {
const elem = document.createElement('input')
elem.value = e.detail.uuid
elem.name = `values[${this.dataset.fieldUuid}][]`
this.prepend(elem)
}
}))
+5
View File
@@ -0,0 +1,5 @@
export default class extends HTMLElement {
setValue (value) {
this.innerHTML = value
}
}
+93
View File
@@ -0,0 +1,93 @@
import { targets, target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
export default actionable(targetable(class extends HTMLElement {
static observedAttributes = ['data-scale']
static [target.static] = [
'form',
'completed',
'submitButton'
]
static [targets.static] = [
'areas',
'fields',
'steps'
]
passValueToArea (e) {
return (this.areas || []).forEach((area) => {
if (area.dataset.fieldUuid === e.target.id) {
area.setValue(e.target.value)
}
})
}
submitSignature () {
this.submitButton.click()
}
setVisibleStep (uuid) {
this.steps.forEach((step) => {
step.classList.toggle('hidden', step.dataset.fieldUuid !== uuid)
})
this.fields.find(f => f.id === uuid).focus()
}
submitForm (e) {
e.preventDefault()
e.submitter.setAttribute('disabled', true)
fetch(this.form.action, {
method: this.form.method,
body: new FormData(this.form)
}).then(response => {
console.log('Form submitted successfully!', response)
this.moveNextStep()
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
e.submitter.removeAttribute('disabled')
})
}
moveStepBack (e) {
e.preventDefault()
const currentStepIndex = this.steps.findIndex((el) => !el.classList.contains('hidden'))
const previousStep = this.steps[currentStepIndex - 1]
if (previousStep) {
this.setVisibleStep(previousStep.dataset.fieldUuid)
}
}
moveNextStep () {
const currentStepIndex = this.steps.findIndex((el) => !el.classList.contains('hidden'))
const nextStep = this.steps[currentStepIndex + 1]
if (nextStep) {
this.setVisibleStep(nextStep.dataset.fieldUuid)
} else {
this.form.classList.add('hidden')
this.completed.classList.remove('hidden')
}
}
focusField ({ target }) {
this.setVisibleStep(target.dataset.fieldUuid)
}
focusArea ({ target }) {
const area = this.areas.find(a => target.id === a.dataset.fieldUuid)
if (area) {
area.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}))
+48
View File
@@ -0,0 +1,48 @@
import SignaturePad from 'signature_pad'
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
import { DirectUpload } from '@rails/activestorage'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'canvas',
'input'
]
connectedCallback () {
this.pad = new SignaturePad(this.canvas)
}
submit (e) {
e?.preventDefault()
this.canvas.toBlob((blob) => {
const file = new File([blob], 'signature.jpg', { type: 'image/jpg' })
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch('/api/attachments', {
method: 'POST',
body: JSON.stringify({
submission_slug: this.dataset.submissionSlug,
blob_signed_id: data.signed_id,
name: 'signatures'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.input.value = attachment.uuid
this.dispatchEvent(new CustomEvent('upload', { details: attachment }))
})
})
}, 'image/jpeg', 0.95)
}
clear (e) {
e?.preventDefault()
this.pad.clear()
}
}))
+11
View File
@@ -0,0 +1,11 @@
import { actionable } from '@github/catalyst/lib/actionable'
export default actionable(class extends HTMLElement {
trigger (event) {
const elementIds = JSON.parse(this.dataset.elementIds)
elementIds.forEach((elementId) => {
document.getElementById(elementId).classList.toggle('hidden', event.target.value !== elementId)
})
}
})
+37
View File
@@ -0,0 +1,37 @@
import { actionable } from '@github/catalyst/lib/actionable'
export default actionable(class extends HTMLElement {
connectedCallback () {
document.body.classList.add('overflow-hidden')
document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.close)
}
disconnectedCallback () {
document.body.classList.remove('overflow-hidden')
document.removeEventListener('keyup', this.onEscKey)
document.removeEventListener('turbo:submit-end', this.handleSubmit)
document.removeEventListener('turbo:before-cache', this.close)
}
onSubmit = (e) => {
if (e.detail.success) {
this.close()
}
}
onEscKey = (e) => {
if (e.code === 'Escape') {
this.close()
}
}
close = (e) => {
e?.preventDefault()
this.remove()
}
})
+13
View File
@@ -0,0 +1,13 @@
import FlowArea from './elements/flow_area'
import FlowView from './elements/flow_view'
import DisableHidden from './elements/disable_hidden'
import FileDropzone from './elements/file_dropzone'
import SignaturePad from './elements/signature_pad'
import FilesList from './elements/files_list'
window.customElements.define('flow-view', FlowView)
window.customElements.define('flow-area', FlowArea)
window.customElements.define('disable-hidden', DisableHidden)
window.customElements.define('file-dropzone', FileDropzone)
window.customElements.define('signature-pad', SignaturePad)
window.customElements.define('files-list', FilesList)
+32
View File
@@ -0,0 +1,32 @@
@config "../../tailwind.flow.config.js";
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
a[href],
input[type='checkbox'],
input[type='submit'],
input[type='image'],
input[type='radio'],
label[for],
select,
button {
cursor: pointer;
}
button .disabled {
display: none;
}
button[disabled] .disabled {
display: initial;
}
button .enabled {
display: initial;
}
button[disabled] .enabled {
display: none;
}
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base
default from: 'from@example.com'
layout 'mailer'
register_interceptor ActionMailerConfigsInterceptor
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class SubmissionMailer < ApplicationMailer
DEFAULT_MESSAGE = "You've been invited to submit documents."
def invitation_email(submission, message: DEFAULT_MESSAGE)
@submission = submission
@message = message
mail(to: @submission.email,
subject: 'You have been invited to submit forms')
end
def copy_to_submitter(submission)
@submission = submission
mail(to: submission.email, subject: 'Here is your copy')
end
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class UserMailer < ApplicationMailer
def invitation_email(user)
@user = user
@token = @user.send(:set_reset_password_token)
mail(to: @user.friendly_name,
subject: 'You have been invited to Docuseal')
end
end
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: accounts
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :flows, dependent: :destroy
has_many :active_users, -> { active }, dependent: :destroy,
inverse_of: :account, class_name: 'User'
end
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
strip_attributes
end
+32
View File
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_encrypted_configs_on_account_id (account_id)
# index_encrypted_configs_on_account_id_and_key (account_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage'
EMAIL_SMTP_KEY = 'action_mailer_smtp'
belongs_to :account
encrypts :value
serialize :value, JSON
end
+45
View File
@@ -0,0 +1,45 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: flows
#
# id :bigint not null, primary key
# deleted_at :datetime
# fields :string not null
# name :string not null
# schema :string not null
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
#
# Indexes
#
# index_flows_on_account_id (account_id)
# index_flows_on_author_id (author_id)
# index_flows_on_slug (slug) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
#
class Flow < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :account
attribute :fields, :string, default: -> { [] }
attribute :schema, :string, default: -> { [] }
attribute :slug, :string, default: -> { SecureRandom.base58(8) }
serialize :fields, JSON
serialize :schema, JSON
has_many_attached :documents
has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) }
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submissions
#
# id :bigint not null, primary key
# completed_at :datetime
# deleted_at :datetime
# email :string not null
# ip :string
# opened_at :datetime
# sent_at :datetime
# slug :string not null
# ua :string
# values :string not null
# created_at :datetime not null
# updated_at :datetime not null
# flow_id :bigint not null
#
# Indexes
#
# index_submissions_on_email (email)
# index_submissions_on_flow_id (flow_id)
# index_submissions_on_slug (slug) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (flow_id => flows.id)
#
class Submission < ApplicationRecord
belongs_to :flow
attribute :values, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(8) }
serialize :values, JSON
has_one_attached :archive
has_many_attached :documents
has_many_attached :attachments
has_many_attached :images
has_many_attached :signatures
scope :active, -> { where(deleted_at: nil) }
end
+66
View File
@@ -0,0 +1,66 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
# current_sign_in_at :datetime
# current_sign_in_ip :string
# deleted_at :datetime
# email :string not null
# encrypted_password :string not null
# failed_attempts :integer default(0), not null
# first_name :string not null
# last_name :string not null
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# role :string not null
# sign_in_count :integer default(0), not null
# unlock_token :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class User < ApplicationRecord
ROLES = %w[admin].freeze
EMAIL_REGEXP =
/[a-z0-9][.']?(?:(?:[a-z0-9_-]++[.'])*[a-z0-9_-]++)*@(?:[a-z0-9]++[.-])*[a-z0-9]++\.[a-z]{2,}/i
belongs_to :account
devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable # if ENV['APP_MULTITENANT']
attribute :role, :string, default: 'admin'
scope :active, -> { where(deleted_at: nil) }
def active_for_authentication?
!deleted_at?
end
def full_name
[first_name, last_name].join(' ')
end
def friendly_name
"#{full_name} <#{email}>"
end
end
+16
View File
@@ -0,0 +1,16 @@
Hellp
<div>
<%= link_to 'Create Flow', new_flow_path, data: { turbo_frame: :modal } %>
<%= link_to 'Storage settings', settings_storage_index_path %>
<%= link_to 'Email settings', settings_email_index_path %>
<%= link_to 'Users', settings_users_path %>
</div>
<div>
<% @flows.each do |flow| %>
<div>
<%= flow.name %> |
<a href="<%= flow_path(flow) %>">edit</a> |
<a href="<%= flow_submissions_path(flow) %>">submissions</a> |
</div>
<% end %>
</div>
@@ -0,0 +1,5 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>
+20
View File
@@ -0,0 +1,20 @@
<h2>Change your password</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= render 'devise/shared/error_messages', resource: %>
<%= f.hidden_field :reset_password_token %>
<div class="field">
<%= f.label :password, 'New password' %><br>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br>
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: 'new-password' %>
</div>
<div class="field">
<%= f.label :password_confirmation, 'Confirm new password' %><br>
<%= f.password_field :password_confirmation, autocomplete: 'new-password' %>
</div>
<div class="actions">
<%= f.submit 'Change my password' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
+12
View File
@@ -0,0 +1,12 @@
<h2>Forgot your password?</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
<%= render 'devise/shared/error_messages', resource: %>
<div class="field">
<%= f.label :email %><br>
<%= f.email_field :email, autofocus: true, autocomplete: 'email' %>
</div>
<div class="actions">
<%= f.submit 'Send me reset password instructions' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
@@ -0,0 +1,34 @@
<h2>Sign up</h2>
<%= form_for('', as: resource_name, url: registration_path) do |f| %>
<%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %>
<div>
<%= ff.label :first_name %>
<%= ff.text_field :first_name, required: true %>
</div>
<div>
<%= ff.label :last_name %>
<%= ff.text_field :last_name, required: true %>
</div>
<% end %>
<%= f.fields_for resource.account do |ff| %>
<div>
<%= ff.label :name, 'Company name' %>
<%= ff.text_field :name, required: true %>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div>
<%= ff.label :email %>
<%= ff.email_field :email, required: true %>
</div>
<div>
<%= ff.label :password %>
<%= ff.password_field :password, required: true %>
</div>
<% end %>
<div class="actions">
<%= f.submit 'Sign up' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
+21
View File
@@ -0,0 +1,21 @@
<h2>Log in</h2>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br>
<%= f.email_field :email, autofocus: true, autocomplete: 'email' %>
</div>
<div class="field">
<%= f.label :password %><br>
<%= f.password_field :password, autocomplete: 'current-password' %>
</div>
<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me, checked: true %>
<%= f.label :remember_me %>
</div>
<% end %>
<div class="actions">
<%= f.submit 'Log in' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
@@ -0,0 +1,14 @@
<% if resource.errors.any? %>
<div id="error_explanation" data-turbo-cache="false">
<h2>
<%= I18n.t('errors.messages.not_saved',
count: resource.errors.count,
resource: resource.class.model_name.human.downcase) %>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
+25
View File
@@ -0,0 +1,25 @@
<%- if controller_name != 'sessions' %>
<%= link_to 'Log in', new_session_path(resource_name) %><br>
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Sign up', new_registration_path %><br>
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name) %><br>
<% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br>
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br>
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br>
<% end %>
<% end %>
+27
View File
@@ -0,0 +1,27 @@
Email settings
<% value = @encrypted_config.value || {} %>
<%= form_for @encrypted_config, url: settings_email_index_path, method: :post, html: { autocomplete: 'off' } do |f| %>
<%= f.fields_for :value do |ff| %>
<div>
<%= ff.label :host %>
<%= ff.text_field :host, value: value['host'], required: true %>
</div>
<div>
<%= ff.label :port %>
<%= ff.text_field :port, value: value['port'], required: true %>
</div>
<div>
<%= ff.label :username %>
<%= ff.text_field :username, value: value['username'], required: true %>
</div>
<div>
<%= ff.label :password %>
<%= ff.password_field :password, value: value['password'], required: true %>
</div>
<div>
<%= ff.label :from_email, 'Send from' %>
<%= ff.email_field :from_email, value: value['from_email'], required: true %>
</div>
<% end %>
<%= f.button button_title %>
<% end %>
+9
View File
@@ -0,0 +1,9 @@
<%= render 'shared/turbo_modal' do %>
<%= form_for @flow, data: { turbo_frame: :_top } do |f| %>
<div>
<%= f.label :name %>
<%= f.text_field :name, required: true %>
</div>
<%= f.button button_title %>
<% end %>
<% end %>
+1
View File
@@ -0,0 +1 @@
<flow-builder data-flow="<%= @flow.to_json(include: { documents: { include: { preview_images: { methods: %i[url metadata filename] } } } }) %>"></flow-builder>
+19
View File
@@ -0,0 +1,19 @@
<h2>Welcome to Docuseal</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= render 'devise/shared/error_messages', resource: %>
<%= f.hidden_field :reset_password_token %>
<div class="field">
<%= f.label :password, 'Set password' %><br>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br>
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: 'new-password' %>
</div>
<div class="field">
<%= f.label :password_confirmation, 'Confirm new password' %><br>
<%= f.password_field :password_confirmation, autocomplete: 'new-password' %>
</div>
<div class="actions">
<%= f.submit 'Save password and Sign in' %>
</div>
<% end %>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>
Docuseal
</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= javascript_pack_tag 'application', defer: true %>
<%= stylesheet_pack_tag 'application', media: 'all' %>
<%= yield :head %>
</head>
<body class="font-sans antialiased font-normal leading-normal bg-white text-gray-700">
<turbo-frame id="modal"></turbo-frame>
<%= render 'shared/navbar' %>
<div>
<%= flash[:notice] || flash[:alert] %>
</div>
<%= yield %>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>
Docuseal
</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= javascript_pack_tag 'flow', defer: true %>
<%= stylesheet_pack_tag 'flow', media: 'all' %>
<%= yield :head %>
</head>
<body class="font-sans antialiased font-normal leading-normal bg-white text-gray-700">
<%= yield %>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
</style>
</head>
<body>
<%= yield %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<%= yield %>
@@ -0,0 +1 @@
Email has bee sent
+30
View File
@@ -0,0 +1,30 @@
Setup
<%= form_for '', url: setup_index_path do |f| %>
<%= f.fields_for @user do |ff| %>
<div>
<%= ff.label :first_name %>
<%= ff.text_field :first_name, required: true %>
</div>
<div>
<%= ff.label :last_name %>
<%= ff.text_field :last_name, required: true %>
</div>
<% end %>
<%= f.fields_for @account do |ff| %>
<div>
<%= ff.label :name, 'Company name' %>
<%= ff.text_field :name, required: true %>
</div>
<% end %>
<%= f.fields_for @user do |ff| %>
<div>
<%= ff.label :email %>
<%= ff.email_field :email, required: true %>
</div>
<div>
<%= ff.label :password %>
<%= ff.password_field :password, required: true %>
</div>
<% end %>
<%= f.button button_title %>
<% end %>
+6
View File
@@ -0,0 +1,6 @@
<span class="enabled">
<%= title %>
</span>
<span class="disabled">
<%= disabled_with %>
</span>
+7
View File
@@ -0,0 +1,7 @@
<% if signed_in? %>
<div>
<%= link_to 'Home', root_path, class: 'bg-red-500' %>
<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete } %>
<%= current_user.email %>
</div>
<% end %>
+11
View File
@@ -0,0 +1,11 @@
<turbo-frame id="modal">
<turbo-modal class="left-52 absolute top-0 z-50 bg-white h-[100vh] w-full">
<div>
Modal window Title
<a href="#" data-action="click:turbo-modal#close">&times;</a>
</div>
<div>
<%= yield %>
</div>
</turbo-modal>
</turbo-frame>
+5
View File
@@ -0,0 +1,5 @@
<p>
Form has been submitted alredy by ypu - thanks!
</p>
<%= button_to button_title('Send copy to Email'), send_submission_email_index_path, params: { flow_slug: @flow.slug, email: params[:email] }, form: { onsubmit: 'event.submitter.disabled = true' } %>
<%# do not allow donwload for securetiy reaosn<a href="">Download documets</a> %>
+9
View File
@@ -0,0 +1,9 @@
You have been invited to submit flow <%= @flow.name %>
<%= form_for @submission, url: start_flow_path(@flow.slug), data: { turbo_frame: :_top }, method: :put do |f| %>
Provide youe email to start
<div>
<%= f.label :email %>
<%= f.email_field :email, required: true %>
</div>
<%= f.button button_title %>
<% end %>
+67
View File
@@ -0,0 +1,67 @@
Storage settings
<% value = @encrypted_config.value || { 'service' => 'disk' } %>
<% configs = value['configs'] || {} %>
<%= form_for @encrypted_config, url: settings_storage_index_path, method: :post, html: { autocomplete: 'off' } do |f| %>
<% options = [['Disk', 'disk'], ['AWS S3', 'aws_s3'], ['Google Cloud', 'google']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>">
<% [['Disk', 'disk'], ['AWS S3', 'aws_s3'], ['Google Cloud', 'google']].each do |(label, val)| %>
<%= f.radio_button :selected, val, checked: value['service'] == val, data: { action: 'change:toggle-visible#trigger' } %>
<%= f.label :selected, label, value: val %>
<% end %>
</toggle-visible>
<disable-hidden id="disk" class="<%= 'hidden' if value['service'] != 'disk' %>">
<%= f.fields_for :value do |ff| %>
<%= ff.hidden_field :service, value: 'disk' %>
<% end %>
<div>
Disk storage - no configs needed but make sure you have a persistant disk (heroku doesnt not have one)
</div>
</disable-hidden>
<disable-hidden id="aws_s3" class="<%= 'hidden' if value['service'] != 'aws_s3' %>">
<%= f.fields_for :value do |ff| %>
<%= ff.hidden_field :service, value: 'aws_s3' %>
<%= ff.fields_for :configs, configs do |fff| %>
<div>
<%= fff.label :access_key_id, 'Access key ID' %>
<%= fff.text_field :access_key_id, value: configs['access_key_id'], required: true %>
</div>
<div>
<%= fff.label :secret_access_key %>
<%= fff.password_field :secret_access_key, value: configs['secret_access_key'], required: true %>
</div>
<div>
<%= fff.label :region %>
<%= fff.text_field :region, value: configs['region'], required: true %>
</div>
<div>
<%= fff.label :bucket %>
<%= fff.text_field :bucket, value: value['service'] == 'aws_s3' ? configs['bucket'] : '', required: true %>
</div>
<div>
<%= fff.label :endpoint %>
<%= fff.text_field :endpoint, value: configs['endpoint'], type: :url %>
</div>
<% end %>
<% end %>
</disable-hidden>
<disable-hidden id="google" class="<%= 'hidden' if value['service'] != 'google' %>">
<%= f.fields_for :value do |ff| %>
<%= ff.hidden_field :service, value: 'google' %>
<%= ff.fields_for :configs, configs do |fff| %>
<div>
<%= fff.label :project, 'Project' %>
<%= fff.text_field :project, value: configs['project'], required: true %>
</div>
<div>
<%= fff.label :bucket %>
<%= fff.text_field :bucket, value: value['service'] == 'google' ? configs['bucket'] : '', required: true %>
</div>
<div>
<%= fff.label :credentials, 'Credentials (JSON key content)' %>
<%= fff.text_area :credentials, value: configs['credentials'], required: true %>
</div>
<% end %>
<% end %>
</disable-hidden>
<%= f.button button_title %>
<% end %>
@@ -0,0 +1,3 @@
<p>Hi</a>
<%= @submission.values %>
<%= link_to 'Download', submission_download_index_url(@submission.slug) %>
@@ -0,0 +1,4 @@
<p>Hi there</p>
<p>You have been invited to submit a form:</p>
<p><%= link_to 'Submit', submit_flow_index_url(slug: @submission.slug) %></p>
<p>If you didn't request this, please ignore this email.</p>
+40
View File
@@ -0,0 +1,40 @@
Submissions
Flow <%= @flow.name %>
Copy share link:
<input autocomplete="off" type="text" class="w-full" value="<%= start_flow_url(slug: @flow.slug) %>" disabled>
<a href="<%= new_flow_submission_path(@flow) %>" class="bg-green-600" data-turbo-frame="modal">Add Recepients</a>
<table>
<tr>
<th>
Email
</th>
<th>
Status
</th>
<th>
</th>
</tr>
<% @submissions.each do |submission| %>
<tr>
<td>
<%= submission.email %>
</td>
<td>
<% if submission.completed_at? %>
Completed
<% elsif submission.opened_at? %>
Opened
<% elsif submission.sent_at? %>
Sent
<% else %>
Awaiting
<% end %>
</td>
<td>
copy link<br>
<%= link_to 'View', submission_path(@flow) %>
<%= button_to 'Remove', submission_path(submission), method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</table>
+19
View File
@@ -0,0 +1,19 @@
<%= render 'shared/turbo_modal' do %>
<%= form_for '', url: flow_submissions_path(@flow), data: { turbo_frame: :_top } do |f| %>
<div>
<%= f.label :emails %>
<%= f.text_area :emails, required: true %>
</div>
<div>
<%= f.check_box :send_email, { onchange: "message_field.classList.toggle('hidden', !event.currentTarget.checked)" } %>
<%= f.label :send_email %>
</div>
<div id="message_field" class="hidden">
Hi There,
<%= f.text_area :message, value: SubmissionMailer::DEFAULT_MESSAGE, required: true %>
Thanks,
<%= current_account.name %>
</div>
<%= f.button button_title %>
<% end %>
<% end %>
+21
View File
@@ -0,0 +1,21 @@
Flow: <%= @submission.flow.name %>
Sub: <%= @submission.slug %>
Copy share link:
<input autocomplete="off" type="text" class="w-full" value="<%= submit_flow_url(slug: @submission.slug) %>" disabled>
<% @submission.flow.fields.each do |field| %>
<div>
<%= field['name'] %>:
<% if ['image', 'signature'].include?(field['type']) %>
<% Array.wrap(@submission.values[field['uuid']]).each do |uuid| %>
<img src="<%= ActiveStorage::Attachment.find_by(uuid:).url %>">
<% end %>
<% elsif ['attachment'].include?(field['type']) %>
<% Array.wrap(@submission.values[field['uuid']]).each do |uuid| %>
<% attachment = ActiveStorage::Attachment.find_by(uuid:) %>
<a href="<%= attachment.url %>"><%= attachment.filename %></a>
<% end %>
<% else %>
<%= @submission.values[field['uuid']] %>
<% end %>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<flow-area data-field-uuid="<%= field['uuid'] %>" data-action="click:flow-view#focusField" data-targets="flow-view.areas" class=" cursor-pointer bg-red-100 absolute" style="width: <%= area['w'] * 100 / page.metadata['width'] %>%; height: <%= area['h'] * 100 / page.metadata['height'] %>%; left: <%= area['x'] * 100 / page.metadata['width'] %>%; top: <%= area['y'] * 100 / page.metadata['height'] %>%">
<%= submission.values[field['uuid']] %>
</flow-area>
+5
View File
@@ -0,0 +1,5 @@
<p>
Form completed - thanks!
</p>
<%= button_to button_title('Send copy to Email'), send_submission_email_index_path, params: { submission_slug: @submission.slug }, form: { onsubmit: 'event.submitter.disabled = true' } %>
<%= button_to button_title('Download documents'), submission_download_index_path(@submission.slug), method: :get, form: { onsubmit: 'event.submitter.disabled = true' } %>
+83
View File
@@ -0,0 +1,83 @@
<% fields_index = Flows.build_field_areas_index(@submission.flow) %>
<flow-view class="mx-auto block" style="max-width: 1000px">
<% @submission.flow.schema.each do |item| %>
<% document = @submission.flow.documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<div class="relative">
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>" loading="lazy">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% fields_index.dig(document.uuid, index)&.each do |values| %>
<%= render 'area', submission: @submission, page:, **values %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<div class="sticky bottom-8 w-full">
<div class="bg-white mx-8 md:mx-32 border p-4 rounded">
<form data-target="flow-view.form" data-action="submit:flow-view#submitForm" action="<%= submit_flow_path(slug: @submission.slug) %>" method="post">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
<input value="put" name="_method" type="hidden">
<% visible_step_index = nil %>
<% @submission.flow.fields.each_with_index do |field, index| %>
<% visible_step_index ||= index if @submission.values[field['uuid']].blank? %>
<disable-hidden data-field-uuid="<%= field['uuid'] %>" data-targets="flow-view.steps" class="block <%= 'hidden' if index != visible_step_index %>">
<% if index != 0 %>
<button data-action="click:flow-view#moveStepBack">
Back
</button>
<% end %>
<label for="<%= field['uuid'] %>"><%= field['name'].presence || 'FIeld' %></label>
<% if index == @submission.flow.fields.size - 1 %>
<input type="hidden" name="completed" value="true">
<% end %>
<% if field['type'].in?(['text', 'date']) %>
<input <%= html_attributes(required: 'true') if field['required'] %> id="<%= field['uuid'] %>" data-targets="flow-view.fields" data-action="input:flow-view#passValueToArea focus:flow-view#focusArea" value="<%= @submission.values[field['uuid']] %>" type="<%= field['type'] %>" name="values[<%= field['uuid'] %>]">
<% elsif field['type'] == 'select' %>
<select <%= html_attributes(required: 'true') if field['required'] %> id="<%= field['uuid'] %>" data-targets="flow-view.fields" data-action="input:flow-view#passValueToArea focus:flow-view#focusArea" name="values[<%= field['uuid'] %>]">
<option value="" disabled selected>Select your option</option>
<% field['options'].each do |option| %>
<option <%= html_attributes(selected: 'true') if @submission.values[field['uuid']] == option %> value="<%= option %>"><%= option %></option>
<% end %>
</select>
<% elsif field['type'] == 'image' || field['type'] == 'attachment' %>
<br>
<files-list data-field-uuid="<%= field['uuid'] %>">
<file-dropzone data-action="upload:files-list#add" data-submission-slug="<%= @submission.slug %>">
<% uuid = SecureRandom.uuid %>
<label for="<%= uuid %>">
Upload
<%= field['name'] || 'Attach' %>
</label>
<input multiple data-target="file-dropzone.input" data-action="change:file-dropzone#onSelectFiles" id="<%= uuid %>" type="file" class="hidden">
</file-dropzone>
</files-list>
<% elsif field['type'] == 'signature' %>
<signature-pad data-submission-slug="<%= @submission.slug %>" data-action="upload:flow-view#submitSignature">
<input data-target="signature-pad.input" type="hidden" name="values[<%= field['uuid'] %>]" value="<%= @submission.values[field['uuid']] %>">
<canvas data-target="signature-pad.canvas">
</canvas>
<button data-action="click:signature-pad#submit">
Ok
</button>
<button data-action="click:signature-pad#clear">
Clear
</button>
</signature-pad>
<% elsif field['type'] == 'radio' %>
<% elsif field['type'] == 'checkbox' %>
<% end %>
</disable-hidden>
<% end %>
<button data-target="flow-view.submitButton" type="submit"><%= button_title %></button>
</form>
<div data-target="flow-view.completed" class="hidden">
<p>
Form completed - thanks!
</p>
<%= button_to 'Send copy to Email', send_submission_email_index_path, params: { submission_slug: @submission.slug }, form: { onsubmit: 'event.submitter.disabled = true' } %>
<%= button_to button_title('Download documents'), submission_download_index_path(@submission.slug), method: :get, form: { onsubmit: 'event.submitter.disabled = true' } %>
</div>
</div>
</div>
</flow-view>
@@ -0,0 +1,4 @@
<p>Hello <%= @user.first_name %>,</p>
<p>You have been invited to Docuseal. You can sign up this through the link below.</p>
<p><%= link_to 'Set my password', invitation_url(reset_password_token: @token) %></p>
<p>If you didn't request this, please ignore this email.</p>
+19
View File
@@ -0,0 +1,19 @@
<%= form_for user, data: { turbo_frame: :_top } do |f| %>
<div>
<%= f.label :first_name %>
<%= f.text_field :first_name, required: true %>
</div>
<div>
<%= f.label :last_name %>
<%= f.text_field :last_name, required: true %>
</div>
<div>
<%= f.label :email %>
<%= f.email_field :email, required: true %>
</div>
<div>
<%= f.label :password %>
<%= f.password_field :password, required: user.new_record? %>
</div>
<%= f.button button_title %>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal' do %>
<%= render 'form', user: @user %>
<% end %>
+37
View File
@@ -0,0 +1,37 @@
<div>
Users
<a href="<%= new_user_path %>" data-turbo-frame="modal">New User</a>
</div>
<table>
<tr>
<th>
User
</th>
<th>
Role
</th>
<th>
Last session
</th>
<th>
</th>
</tr>
<% @users.each do |user| %>
<tr>
<td>
<%= user.full_name %><br>
<%= user.email %>
</td>
<td>
<%= user.role %>
</td>
<td>
<%= user.last_sign_in_at ? l(user.last_sign_in_at) : '-' %>
</td>
<td>
<%= link_to 'Edit', edit_user_path(user), data: { turbo_frame: 'modal' } %>
<%= button_to 'Remove', user_path(user), method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
</td>
</tr>
<% end %>
</table>
+3
View File
@@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal' do %>
<%= render 'form', user: @user %>
<% end %>