add revisions

This commit is contained in:
Pete Matsyburka
2026-05-07 09:48:25 +03:00
parent 04ec2f8260
commit 10fd624bec
16 changed files with 462 additions and 17 deletions
+2
View File
@@ -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,
+139 -7
View File
@@ -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}"]`)
+35 -7
View File
@@ -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')"
>&times;</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>