diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index f32e1e1e..11968e4f 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -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 diff --git a/app/controllers/templates_versions_controller.rb b/app/controllers/templates_versions_controller.rb new file mode 100644 index 00000000..c9a67b8f --- /dev/null +++ b/app/controllers/templates_versions_controller.rb @@ -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 diff --git a/app/javascript/application.js b/app/javascript/application.js index ff617caa..af25f2b4 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index b805af92..549cb1c6 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -51,6 +51,30 @@ +
{{ t('preferences') }} +
  • + +
  • @@ -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(() => { diff --git a/app/javascript/template_builder/conditions_modal.vue b/app/javascript/template_builder/conditions_modal.vue index d8219ff6..0b17affc 100644 --- a/app/javascript/template_builder/conditions_modal.vue +++ b/app/javascript/template_builder/conditions_modal.vue @@ -6,7 +6,7 @@ class="absolute top-0 bottom-0 right-0 left-0" @click.prevent="$emit('close')" /> -