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')
|
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||||
|
|
||||||
|
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
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',
|
withLogo: this.dataset.withLogo !== 'false',
|
||||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||||
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
||||||
|
withRevisions: true,
|
||||||
|
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
|
||||||
editable: this.dataset.editable !== 'false',
|
editable: this.dataset.editable !== 'false',
|
||||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||||
withCustomFields: true,
|
withCustomFields: true,
|
||||||
|
|||||||
@@ -51,6 +51,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="$slots.buttons || withTitle"
|
v-if="$slots.buttons || withTitle"
|
||||||
id="title_container"
|
id="title_container"
|
||||||
@@ -213,6 +237,18 @@
|
|||||||
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li v-if="withDownload">
|
||||||
<button
|
<button
|
||||||
class="flex space-x-2"
|
class="flex space-x-2"
|
||||||
@@ -600,7 +636,16 @@
|
|||||||
<div
|
<div
|
||||||
id="docuseal_modal_container"
|
id="docuseal_modal_container"
|
||||||
class="modal-container"
|
class="modal-container"
|
||||||
/>
|
>
|
||||||
|
<RevisionsModal
|
||||||
|
v-if="isRevisionsModalOpen"
|
||||||
|
:template="template"
|
||||||
|
:revisions="revisions"
|
||||||
|
:locale="locale"
|
||||||
|
@close="isRevisionsModalOpen = false"
|
||||||
|
@apply="onRevisionApply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -618,7 +663,8 @@ import DocumentPreview from './preview'
|
|||||||
import DocumentControls from './controls'
|
import DocumentControls from './controls'
|
||||||
import MobileFields from './mobile_fields'
|
import MobileFields from './mobile_fields'
|
||||||
import FieldSubmitter from './field_submitter'
|
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 { v4 } from 'uuid'
|
||||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||||
import * as i18n from './i18n'
|
import * as i18n from './i18n'
|
||||||
@@ -658,7 +704,9 @@ export default {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconDeviceFloppy
|
IconHistory,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
RevisionsModal
|
||||||
},
|
},
|
||||||
provide () {
|
provide () {
|
||||||
return {
|
return {
|
||||||
@@ -980,6 +1028,16 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
withRevisions: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
withRevisionsMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
@@ -1002,7 +1060,10 @@ export default {
|
|||||||
drawOption: null,
|
drawOption: null,
|
||||||
dragField: null,
|
dragField: null,
|
||||||
isDragFile: false,
|
isDragFile: false,
|
||||||
isMathLoaded: false
|
isMathLoaded: false,
|
||||||
|
isRevisionsModalOpen: false,
|
||||||
|
revisions: [],
|
||||||
|
beforeRevisionSnapshot: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1767,6 +1828,72 @@ export default {
|
|||||||
closeDropdown () {
|
closeDropdown () {
|
||||||
document.activeElement.blur()
|
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) {
|
t (key) {
|
||||||
return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || 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())
|
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}`)
|
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.isSaving = false
|
this.isSaving = false
|
||||||
@@ -3244,9 +3371,13 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
save ({ force } = { force: false }) {
|
save ({ force = false, revision = false } = {}) {
|
||||||
this.pendingFieldAttachmentUuids = []
|
this.pendingFieldAttachmentUuids = []
|
||||||
|
|
||||||
|
if (this.beforeRevisionSnapshot) {
|
||||||
|
this.beforeRevisionSnapshot = null
|
||||||
|
}
|
||||||
|
|
||||||
if (this.onChange) {
|
if (this.onChange) {
|
||||||
this.onChange(this.template)
|
this.onChange(this.template)
|
||||||
}
|
}
|
||||||
@@ -3272,7 +3403,8 @@ export default {
|
|||||||
submitters: this.template.submitters,
|
submitters: this.template.submitters,
|
||||||
fields: this.template.fields,
|
fields: this.template.fields,
|
||||||
variables_schema: this.template.variables_schema
|
variables_schema: this.template.variables_schema
|
||||||
}
|
},
|
||||||
|
...(revision ? { revision: true } : {})
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
class="absolute top-0 bottom-0 right-0 left-0"
|
class="absolute top-0 bottom-0 right-0 left-0"
|
||||||
@click.prevent="$emit('close')"
|
@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">
|
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||||
<span class="modal-title">
|
<span class="modal-title">
|
||||||
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
|
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export default {
|
|||||||
this.sectionRefs.push(ref)
|
this.sectionRefs.push(ref)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
reloadContent () {
|
||||||
|
this.sectionRefs.forEach((ref) => ref.reloadContent())
|
||||||
|
},
|
||||||
onBeforeUnload (event) {
|
onBeforeUnload (event) {
|
||||||
if (this.saveTimer) {
|
if (this.saveTimer) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -281,6 +281,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
reloadContent () {
|
||||||
|
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
|
||||||
|
},
|
||||||
findAreaNodePos (areaUuid) {
|
findAreaNodePos (areaUuid) {
|
||||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ const en = {
|
|||||||
align_bottom: 'Align Bottom',
|
align_bottom: 'Align Bottom',
|
||||||
fields_selected: '{count} Fields Selected',
|
fields_selected: '{count} Fields Selected',
|
||||||
field_added: '{count} Field Added',
|
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 = {
|
const es = {
|
||||||
@@ -435,7 +439,11 @@ const es = {
|
|||||||
align_bottom: 'Alinear abajo',
|
align_bottom: 'Alinear abajo',
|
||||||
fields_selected: '{count} Campos Seleccionados',
|
fields_selected: '{count} Campos Seleccionados',
|
||||||
field_added: '{count} Campo Añadido',
|
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 = {
|
const it = {
|
||||||
@@ -655,7 +663,11 @@ const it = {
|
|||||||
align_bottom: 'Allinea in basso',
|
align_bottom: 'Allinea in basso',
|
||||||
fields_selected: '{count} Campi Selezionati',
|
fields_selected: '{count} Campi Selezionati',
|
||||||
field_added: '{count} Campo Aggiunto',
|
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 = {
|
const pt = {
|
||||||
@@ -875,7 +887,11 @@ const pt = {
|
|||||||
align_bottom: 'Alinhar à parte inferior',
|
align_bottom: 'Alinhar à parte inferior',
|
||||||
fields_selected: '{count} Campos Selecionados',
|
fields_selected: '{count} Campos Selecionados',
|
||||||
field_added: '{count} Campo Adicionado',
|
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 = {
|
const fr = {
|
||||||
@@ -1095,7 +1111,11 @@ const fr = {
|
|||||||
align_bottom: 'Aligner en bas',
|
align_bottom: 'Aligner en bas',
|
||||||
fields_selected: '{count} Champs Sélectionnés',
|
fields_selected: '{count} Champs Sélectionnés',
|
||||||
field_added: '{count} Champ Ajouté',
|
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 = {
|
const de = {
|
||||||
@@ -1315,7 +1335,11 @@ const de = {
|
|||||||
align_bottom: 'Unten ausrichten',
|
align_bottom: 'Unten ausrichten',
|
||||||
fields_selected: '{count} Felder Ausgewählt',
|
fields_selected: '{count} Felder Ausgewählt',
|
||||||
field_added: '{count} Feld Hinzugefügt',
|
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 = {
|
const nl = {
|
||||||
@@ -1535,7 +1559,11 @@ const nl = {
|
|||||||
align_bottom: 'Onder uitlijnen',
|
align_bottom: 'Onder uitlijnen',
|
||||||
fields_selected: '{count} Velden Geselecteerd',
|
fields_selected: '{count} Velden Geselecteerd',
|
||||||
field_added: '{count} Veld Toegevoegd',
|
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 }
|
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 :submissions, dependent: :destroy
|
||||||
has_many :template_sharings, dependent: :destroy
|
has_many :template_sharings, dependent: :destroy
|
||||||
has_many :template_accesses, dependent: :destroy
|
has_many :template_accesses, dependent: :destroy
|
||||||
|
has_many :template_versions, dependent: :destroy
|
||||||
has_many :dynamic_documents, dependent: :destroy
|
has_many :dynamic_documents, dependent: :destroy
|
||||||
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
|
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' %>
|
<%= 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 %>
|
||||||
<% 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 :form, only: %i[show], controller: 'templates_form_preview'
|
||||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||||
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
|
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, only: %i[show create], controller: 'templates_share_link'
|
||||||
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
|
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
|
||||||
resources :recipients, only: %i[create], controller: 'templates_recipients'
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "btree_gin"
|
enable_extension "btree_gin"
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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"
|
t.index ["template_id"], name: "index_template_sharings_on_template_id"
|
||||||
end
|
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|
|
create_table "templates", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.datetime "archived_at"
|
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", "template_folders", column: "parent_folder_id"
|
||||||
add_foreign_key "template_folders", "users", column: "author_id"
|
add_foreign_key "template_folders", "users", column: "author_id"
|
||||||
add_foreign_key "template_sharings", "templates"
|
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", "accounts"
|
||||||
add_foreign_key "templates", "template_folders", column: "folder_id"
|
add_foreign_key "templates", "template_folders", column: "folder_id"
|
||||||
add_foreign_key "templates", "users", column: "author_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