mirror of
https://github.com/docusealco/docuseal.git
synced 2026-06-23 04:10:11 +00:00
add revisions
This commit is contained in:
@@ -77,6 +77,8 @@ class TemplatesController < ApplicationController
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesVersionsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
versions = @template.template_versions.order(id: :desc).preload(:author)
|
||||
|
||||
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
|
||||
end
|
||||
|
||||
def show
|
||||
version = @template.template_versions.find(params[:id])
|
||||
|
||||
render json: TemplateVersions.serialize(version)
|
||||
end
|
||||
end
|
||||
@@ -170,6 +170,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
||||
withRevisions: true,
|
||||
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
|
||||
@@ -51,6 +51,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="beforeRevisionSnapshot"
|
||||
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
|
||||
>
|
||||
<div class="alert border-base-content/30 py-2 px-2.5">
|
||||
<IconInfoCircle class="stroke-info shrink-0 w-6 h-6" />
|
||||
<span>{{ t('viewing_revision_from').replace('{date}', formatRevisionTime(beforeRevisionSnapshot.revision.created_at)) }}</span>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click.prevent="cancelRevision"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editable"
|
||||
class="btn btn-sm btn-neutral text-white"
|
||||
@click.prevent="applyRevision"
|
||||
>
|
||||
{{ t('apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.buttons || withTitle"
|
||||
id="title_container"
|
||||
@@ -213,6 +237,18 @@
|
||||
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="withRevisionsMenu">
|
||||
<button
|
||||
class="flex space-x-2"
|
||||
@click.prevent="openRevisionsModal"
|
||||
@mouseenter="preloadRevisions"
|
||||
>
|
||||
<span class="w-6 h-6 flex-shrink-0 flex items-center justify-center">
|
||||
<IconHistory class="w-5 h-5" />
|
||||
</span>
|
||||
<span class="whitespace-nowrap">{{ t('revisions') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="withDownload">
|
||||
<button
|
||||
class="flex space-x-2"
|
||||
@@ -600,7 +636,16 @@
|
||||
<div
|
||||
id="docuseal_modal_container"
|
||||
class="modal-container"
|
||||
/>
|
||||
>
|
||||
<RevisionsModal
|
||||
v-if="isRevisionsModalOpen"
|
||||
:template="template"
|
||||
:revisions="revisions"
|
||||
:locale="locale"
|
||||
@close="isRevisionsModalOpen = false"
|
||||
@apply="onRevisionApply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -618,7 +663,8 @@ import DocumentPreview from './preview'
|
||||
import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
|
||||
import RevisionsModal from './revisions_modal'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
@@ -658,7 +704,9 @@ export default {
|
||||
IconDownload,
|
||||
IconAdjustments,
|
||||
IconEye,
|
||||
IconDeviceFloppy
|
||||
IconHistory,
|
||||
IconDeviceFloppy,
|
||||
RevisionsModal
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
@@ -980,6 +1028,16 @@ export default {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withRevisions: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withRevisionsMenu: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -1002,7 +1060,10 @@ export default {
|
||||
drawOption: null,
|
||||
dragField: null,
|
||||
isDragFile: false,
|
||||
isMathLoaded: false
|
||||
isMathLoaded: false,
|
||||
isRevisionsModalOpen: false,
|
||||
revisions: [],
|
||||
beforeRevisionSnapshot: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -1767,6 +1828,72 @@ export default {
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
preloadRevisions () {
|
||||
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
|
||||
},
|
||||
openRevisionsModal () {
|
||||
this.closeDropdown()
|
||||
|
||||
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
|
||||
|
||||
this.loadRevisionsPromise.then(async (resp) => {
|
||||
this.revisions = await resp.json()
|
||||
|
||||
this.isRevisionsModalOpen = true
|
||||
this.loadRevisionsPromise = null
|
||||
})
|
||||
},
|
||||
onRevisionApply (revision) {
|
||||
this.beforeRevisionSnapshot = {
|
||||
template: JSON.parse(JSON.stringify(this.template)),
|
||||
dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)),
|
||||
revision
|
||||
}
|
||||
|
||||
const { dynamic_documents: nextDynamicDocs = [], ...nextTemplate } = revision.data
|
||||
|
||||
Object.assign(this.template, nextTemplate)
|
||||
|
||||
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...nextDynamicDocs)
|
||||
|
||||
this.$nextTick(() => this.reloadDynamicDocumentContent())
|
||||
|
||||
this.isRevisionsModalOpen = false
|
||||
},
|
||||
cancelRevision () {
|
||||
Object.assign(this.template, this.beforeRevisionSnapshot.template)
|
||||
|
||||
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...this.beforeRevisionSnapshot.dynamicDocuments)
|
||||
|
||||
this.beforeRevisionSnapshot = null
|
||||
|
||||
this.$nextTick(() => this.reloadDynamicDocumentContent())
|
||||
},
|
||||
applyRevision () {
|
||||
this.beforeRevisionSnapshot = null
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
dynamicDocumentRefs.forEach((ref) => ref.update())
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
return Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())])
|
||||
},
|
||||
reloadDynamicDocumentContent () {
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.isDynamic) ref.reloadContent()
|
||||
})
|
||||
},
|
||||
formatRevisionTime (string) {
|
||||
return new Date(string).toLocaleString(this.locale || undefined, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
},
|
||||
t (key) {
|
||||
return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || key
|
||||
},
|
||||
@@ -3013,7 +3140,7 @@ export default {
|
||||
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
|
||||
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
@@ -3244,9 +3371,13 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
save ({ force } = { force: false }) {
|
||||
save ({ force = false, revision = false } = {}) {
|
||||
this.pendingFieldAttachmentUuids = []
|
||||
|
||||
if (this.beforeRevisionSnapshot) {
|
||||
this.beforeRevisionSnapshot = null
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.template)
|
||||
}
|
||||
@@ -3272,7 +3403,8 @@ export default {
|
||||
submitters: this.template.submitters,
|
||||
fields: this.template.fields,
|
||||
variables_schema: this.template.variables_schema
|
||||
}
|
||||
},
|
||||
...(revision ? { revision: true } : {})
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
|
||||
|
||||
@@ -183,6 +183,9 @@ export default {
|
||||
this.sectionRefs.push(ref)
|
||||
}
|
||||
},
|
||||
reloadContent () {
|
||||
this.sectionRefs.forEach((ref) => ref.reloadContent())
|
||||
},
|
||||
onBeforeUnload (event) {
|
||||
if (this.saveTimer) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -281,6 +281,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
reloadContent () {
|
||||
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
|
||||
@@ -215,7 +215,11 @@ const en = {
|
||||
align_bottom: 'Align Bottom',
|
||||
fields_selected: '{count} Fields Selected',
|
||||
field_added: '{count} Field Added',
|
||||
fields_added: '{count} Fields Added'
|
||||
fields_added: '{count} Fields Added',
|
||||
revisions: 'Revisions',
|
||||
apply: 'Apply',
|
||||
no_revisions_yet: 'No revisions yet',
|
||||
viewing_revision_from: 'Viewing revision from {date}'
|
||||
}
|
||||
|
||||
const es = {
|
||||
@@ -435,7 +439,11 @@ const es = {
|
||||
align_bottom: 'Alinear abajo',
|
||||
fields_selected: '{count} Campos Seleccionados',
|
||||
field_added: '{count} Campo Añadido',
|
||||
fields_added: '{count} Campos Añadidos'
|
||||
fields_added: '{count} Campos Añadidos',
|
||||
revisions: 'Revisiones',
|
||||
apply: 'Aplicar',
|
||||
no_revisions_yet: 'Aún no hay revisiones',
|
||||
viewing_revision_from: 'Viendo revisión del {date}'
|
||||
}
|
||||
|
||||
const it = {
|
||||
@@ -655,7 +663,11 @@ const it = {
|
||||
align_bottom: 'Allinea in basso',
|
||||
fields_selected: '{count} Campi Selezionati',
|
||||
field_added: '{count} Campo Aggiunto',
|
||||
fields_added: '{count} Campi Aggiunti'
|
||||
fields_added: '{count} Campi Aggiunti',
|
||||
revisions: 'Revisioni',
|
||||
apply: 'Applica',
|
||||
no_revisions_yet: 'Nessuna revisione ancora',
|
||||
viewing_revision_from: 'Visualizzazione revisione del {date}'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
@@ -875,7 +887,11 @@ const pt = {
|
||||
align_bottom: 'Alinhar à parte inferior',
|
||||
fields_selected: '{count} Campos Selecionados',
|
||||
field_added: '{count} Campo Adicionado',
|
||||
fields_added: '{count} Campos Adicionados'
|
||||
fields_added: '{count} Campos Adicionados',
|
||||
revisions: 'Revisões',
|
||||
apply: 'Aplicar',
|
||||
no_revisions_yet: 'Nenhuma revisão ainda',
|
||||
viewing_revision_from: 'Visualizando revisão de {date}'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
@@ -1095,7 +1111,11 @@ const fr = {
|
||||
align_bottom: 'Aligner en bas',
|
||||
fields_selected: '{count} Champs Sélectionnés',
|
||||
field_added: '{count} Champ Ajouté',
|
||||
fields_added: '{count} Champs Ajoutés'
|
||||
fields_added: '{count} Champs Ajoutés',
|
||||
revisions: 'Révisions',
|
||||
apply: 'Appliquer',
|
||||
no_revisions_yet: 'Aucune révision pour le moment',
|
||||
viewing_revision_from: 'Affichage de la révision du {date}'
|
||||
}
|
||||
|
||||
const de = {
|
||||
@@ -1315,7 +1335,11 @@ const de = {
|
||||
align_bottom: 'Unten ausrichten',
|
||||
fields_selected: '{count} Felder Ausgewählt',
|
||||
field_added: '{count} Feld Hinzugefügt',
|
||||
fields_added: '{count} Felder Hinzugefügt'
|
||||
fields_added: '{count} Felder Hinzugefügt',
|
||||
revisions: 'Revisionen',
|
||||
apply: 'Anwenden',
|
||||
no_revisions_yet: 'Noch keine Revisionen',
|
||||
viewing_revision_from: 'Ansicht der Revision vom {date}'
|
||||
}
|
||||
|
||||
const nl = {
|
||||
@@ -1535,7 +1559,11 @@ const nl = {
|
||||
align_bottom: 'Onder uitlijnen',
|
||||
fields_selected: '{count} Velden Geselecteerd',
|
||||
field_added: '{count} Veld Toegevoegd',
|
||||
fields_added: '{count} Velden Toegevoegd'
|
||||
fields_added: '{count} Velden Toegevoegd',
|
||||
revisions: 'Revisies',
|
||||
apply: 'Toepassen',
|
||||
no_revisions_yet: 'Nog geen revisies',
|
||||
viewing_revision_from: 'Revisie van {date} bekijken'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de, nl }
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="modal modal-open items-start !animate-none overflow-y-auto">
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 mt-20 w-full">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-3 font-medium">
|
||||
<span>{{ t('revisions') }}</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<ul class="space-y-1.5">
|
||||
<li
|
||||
v-for="revision in revisions"
|
||||
:key="revision.id"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded-lg p-3 border border-dashed border-base-200 transition-colors disabled:cursor-default hover:bg-base-200 hover:border-base-200"
|
||||
:disabled="loadingId !== null"
|
||||
@click="viewRevision(revision.id)"
|
||||
>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{{ formatDate(revision.created_at) }}</span>
|
||||
<span class="-ml-0.5 flex items-center space-x-1 text-xs text-base-content/60 mt-0.5">
|
||||
<IconUser class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="truncate">{{ revision.author.full_name || revision.author.email }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="btn btn-sm btn-neutral text-white pointer-events-none flex-shrink-0">
|
||||
<IconInnerShadowTop
|
||||
v-if="loadingId === revision.id"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<span v-else>{{ t('view') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
v-if="!revisions.length"
|
||||
class="py-4 text-center text-base-content/60"
|
||||
>
|
||||
{{ t('no_revisions_yet') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'RevisionsModal',
|
||||
components: { IconUser, IconInnerShadowTop },
|
||||
inject: ['t', 'baseFetch'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
revisions: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close', 'apply'],
|
||||
data () {
|
||||
return {
|
||||
loadingId: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
viewRevision (id) {
|
||||
if (this.loadingId !== null) return
|
||||
|
||||
this.loadingId = id
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/versions/${id}`)
|
||||
.then((r) => r.json())
|
||||
.then((revision) => { this.$emit('apply', revision) })
|
||||
.finally(() => { this.loadingId = null })
|
||||
},
|
||||
formatDate (string) {
|
||||
return new Date(string).toLocaleString(this.locale || undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -70,6 +70,7 @@ class Template < ApplicationRecord
|
||||
has_many :submissions, dependent: :destroy
|
||||
has_many :template_sharings, dependent: :destroy
|
||||
has_many :template_accesses, dependent: :destroy
|
||||
has_many :template_versions, dependent: :destroy
|
||||
has_many :dynamic_documents, dependent: :destroy
|
||||
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: template_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# data :text not null
|
||||
# sha1 :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_template_versions_on_account_id (account_id)
|
||||
# index_template_versions_on_author_id (author_id)
|
||||
# index_template_versions_on_template_id_and_sha1 (template_id,sha1) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (author_id => users.id)
|
||||
# fk_rails_... (template_id => templates.id)
|
||||
#
|
||||
class TemplateVersion < ApplicationRecord
|
||||
belongs_to :template
|
||||
belongs_to :account
|
||||
belongs_to :author, class_name: 'User'
|
||||
|
||||
attribute :data, :string, default: -> { {} }
|
||||
|
||||
serialize :data, coder: JSON
|
||||
|
||||
before_validation :set_account, on: :create
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
self.account ||= template.account
|
||||
end
|
||||
end
|
||||
@@ -6,4 +6,4 @@
|
||||
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
|
||||
@@ -108,6 +108,7 @@ Rails.application.routes.draw do
|
||||
resource :form, only: %i[show], controller: 'templates_form_preview'
|
||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
|
||||
resources :versions, only: %i[index show], controller: 'templates_versions'
|
||||
resource :share_link, only: %i[show create], controller: 'templates_share_link'
|
||||
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
|
||||
resources :recipients, only: %i[create], controller: 'templates_recipients'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateTemplateVersions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :template_versions do |t|
|
||||
t.references :template, null: false, foreign_key: true, index: false
|
||||
t.references :account, null: false, foreign_key: true, index: true
|
||||
t.references :author, null: false, foreign_key: { to_table: :users }, index: true
|
||||
t.text :data, null: false
|
||||
t.string :sha1, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :template_versions, %i[template_id sha1], unique: true
|
||||
end
|
||||
end
|
||||
+17
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_05_06_120000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_05_06_121640) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
@@ -444,6 +444,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_06_120000) do
|
||||
t.index ["template_id"], name: "index_template_sharings_on_template_id"
|
||||
end
|
||||
|
||||
create_table "template_versions", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "author_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.text "data", null: false
|
||||
t.string "sha1", null: false
|
||||
t.bigint "template_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_template_versions_on_account_id"
|
||||
t.index ["author_id"], name: "index_template_versions_on_author_id"
|
||||
t.index ["template_id", "sha1"], name: "index_template_versions_on_template_id_and_sha1", unique: true
|
||||
end
|
||||
|
||||
create_table "templates", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "archived_at"
|
||||
@@ -587,6 +600,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_06_120000) do
|
||||
add_foreign_key "template_folders", "template_folders", column: "parent_folder_id"
|
||||
add_foreign_key "template_folders", "users", column: "author_id"
|
||||
add_foreign_key "template_sharings", "templates"
|
||||
add_foreign_key "template_versions", "accounts"
|
||||
add_foreign_key "template_versions", "templates"
|
||||
add_foreign_key "template_versions", "users", column: "author_id"
|
||||
add_foreign_key "templates", "accounts"
|
||||
add_foreign_key "templates", "template_folders", column: "folder_id"
|
||||
add_foreign_key "templates", "users", column: "author_id"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module TemplateVersions
|
||||
SERIALIZE_PARAMS = {
|
||||
only: %i[id created_at],
|
||||
include: { author: { only: %i[email], methods: %i[full_name] } }
|
||||
}.freeze
|
||||
|
||||
DATA_FIELDS = %i[name schema submitters variables_schema fields].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def find_or_create_for(template, author:)
|
||||
data = build_data(template)
|
||||
sha1 = Digest::SHA1.hexdigest(data.to_json)
|
||||
|
||||
version = template.template_versions.find_by(sha1:)
|
||||
version ||= template.template_versions.create!(data:, sha1:, author:)
|
||||
|
||||
version
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
end
|
||||
|
||||
def serialize(version)
|
||||
data = version.data.dup
|
||||
|
||||
data['documents'] = serialize_documents(version.template, data['schema'].to_a)
|
||||
data['dynamic_documents'] = serialize_dynamic_documents(version.template, data['dynamic_documents'].to_a)
|
||||
|
||||
version.as_json(SERIALIZE_PARAMS).merge('data' => data)
|
||||
end
|
||||
|
||||
def build_data(template)
|
||||
dynamic_uuids = template.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid')
|
||||
|
||||
dynamic_documents =
|
||||
if dynamic_uuids.present?
|
||||
template.dynamic_documents.where(uuid: dynamic_uuids).as_json(only: %i[uuid body])
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
template.as_json(only: DATA_FIELDS).merge('dynamic_documents' => dynamic_documents)
|
||||
end
|
||||
|
||||
def serialize_documents(template, schema)
|
||||
return [] if schema.blank?
|
||||
|
||||
template.documents_attachments
|
||||
.where(uuid: schema.pluck('attachment_uuid'))
|
||||
.preload(:blob, preview_images_attachments: :blob)
|
||||
.as_json(
|
||||
only: %i[id uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_dynamic_documents(template, dynamic_docs)
|
||||
return [] if dynamic_docs.blank?
|
||||
|
||||
dynamic_docs_index = template.dynamic_documents
|
||||
.where(uuid: dynamic_docs.pluck('uuid'))
|
||||
.preload(attachments_attachments: :blob)
|
||||
.index_by(&:uuid)
|
||||
|
||||
dynamic_docs.map do |attrs|
|
||||
document = dynamic_docs_index[attrs['uuid']]
|
||||
|
||||
attachments_data = document.attachments_attachments.as_json(only: %i[uuid], methods: %i[url metadata filename])
|
||||
|
||||
attrs.merge('head' => document.head, 'attachments' => attachments_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user