diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb index 3ac89c9b..61aea897 100644 --- a/app/models/metadata_file.rb +++ b/app/models/metadata_file.rb @@ -49,6 +49,8 @@ class MetadataFile < MetadataTemplate value['path'] = relative_destination_path_with_filename.to_s if static_file end + self[:value] = self[:value].to_h + true end diff --git a/app/models/metadata_has_one_nested.rb b/app/models/metadata_has_one_nested.rb new file mode 100644 index 00000000..2f3e8e02 --- /dev/null +++ b/app/models/metadata_has_one_nested.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MetadataHasOneNested < MetadataHasOne + def nested + @nested ||= layout.metadata.dig(name, 'nested') + end + + def nested? + true + end + + # No tener conflictos con related + def related_methods + @related_methods ||= [].freeze + end +end diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 78989e15..c762220e 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -12,6 +12,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, false end + def nested? + false + end + def inspect "#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>" end @@ -38,18 +42,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, "#{cache_key}-#{cache_version}" end - # XXX: Deberíamos sanitizar durante la asignación? - def value=(new_value) - @value_was = value - self[:value] = new_value - end - # Siempre obtener el valor actual y solo obtenerlo del documento una # vez. def value_was - return @value_was if instance_variable_defined? '@value_was' - - @value_was = document_value + @value_was ||= document_value.nil? ? default_value : document_value end def changed? @@ -169,7 +165,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, # once => el campo solo se puede modificar si estaba vacío def writable? case layout.metadata.dig(name, 'writable') - when 'once' then value.blank? + when 'once' then value_was.blank? else true end end diff --git a/app/models/post.rb b/app/models/post.rb index 8885897f..327df3e2 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -15,6 +15,9 @@ class Post PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze ATTR_SUFFIXES = %w[? =].freeze + class PostError < StandardError; end + class UnknownAttributeError < PostError; end + attr_reader :attributes, :errors, :layout, :site, :document # TODO: Modificar el historial de Git con callbacks en lugar de @@ -50,12 +53,33 @@ class Post @errors = {} @metadata = {} - # Inicializar valores + # Leer el documento si existe + # @todo Asignar todos los valores a self[:value] luego de leer + document&.read! unless new? + + # Inicializar valores o modificar los que vengan del documento + assignable_attributes = args.slice(*attributes) + assign_attributes(assignable_attributes) if assignable_attributes.present? + end + + # Asignar atributos, ignorando atributos que no se pueden modificar + # o inexistentes + # + # @param attrs [Hash] + def assign_attributes(attrs) + attrs = attrs.transform_keys(&:to_sym) + attributes.each do |attr| - public_send(attr)&.value = args[attr] if args.key?(attr) + self[attr].value = attrs[attr] if attrs.key?(attr) && self[attr].writable? end - document.read! unless new? + unknown_attrs = attrs.keys.map(&:to_sym) - attributes + + if unknown_attrs.present? + raise UnknownAttributeError, "Unknown attribute(s) #{unknown_attrs.map(&:to_s).join(', ')} for Post" + end + + nil end def inspect @@ -165,8 +189,7 @@ class Post # Limpiar el nombre del atributo, para que todos los ayudantes # reciban el método en limpio unless attribute? name - raise NoMethodError, I18n.t('exceptions.post.no_method', - method: name) + raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) end define_singleton_method(name) do @@ -386,11 +409,7 @@ class Post end def update_attributes(hashable) - hashable.to_hash.each do |attr, value| - next unless self[attr].writable? - - self[attr].value = value - end + assign_attributes(hashable) save end @@ -404,6 +423,13 @@ class Post @usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a end + # Todos los atributos anidados + # + # @return [Array] + def nested_attributes + @nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name) + end + private # Levanta un error si al construir el artículo no pasamos un atributo. diff --git a/app/services/post_service.rb b/app/services/post_service.rb index 4631a9a4..84f58dad 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -10,13 +10,19 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do self.post = site.posts(lang: locale) .build(layout: layout) post.usuaries << usuarie - params[:post][:draft] = true if site.invitade? usuarie + post.draft.value = true if site.invitade? usuarie + post.assign_attributes(post_params) params.require(:post).permit(:slug).tap do |p| post.slug.value = p[:slug] if p[:slug].present? end - commit(action: :created, add: update_related_posts) if post.update(post_params) + # Crea los posts anidados + create_nested_posts! post, params[:post] + post.save + update_related_posts + + commit(action: :created, add: files) update_site_license! @@ -34,7 +40,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # Los artículos anónimos siempre son borradores params[:draft] = true - commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params) + commit(action: :created, add: files) if post.update(anon_post_params) post end @@ -47,9 +53,12 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do rm = [] rm << post.path.value_was if post.path.changed? + create_nested_posts! post, params[:post] + update_related_posts + # Es importante que el artículo se guarde primero y luego los # relacionados. - commit(action: :updated, add: update_related_posts, rm: rm) + commit(action: :updated, add: files, rm: rm) update_site_license! end @@ -96,6 +105,15 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do private + # Una lista de archivos a modificar + # + # @return [Set] + def files + @files ||= Set.new.tap do |f| + f << post.path.absolute + end + end + def commit(action:, add: [], rm: []) site.repository.commit(add: add, rm: rm, @@ -108,7 +126,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # Solo permitir cambiar estos atributos de cada articulo def post_params - params.require(:post).permit(post.params) + @post_params ||= params.require(:post).permit(post.params).to_h end # Eliminar metadatos internos @@ -146,8 +164,10 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do end posts.map do |p| - p.path.absolute if p.save(validate: false) - end.compact << post.path.absolute + next unless p.save(validate: false) + + files << p.path.absolute + end end # Si les usuaries modifican o crean una licencia, considerarla @@ -157,4 +177,20 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do site.update licencia: Licencia.find_by_icons('custom') end end + + # Encuentra todos los posts anidados y los crea o modifica + def create_nested_posts!(post, params) + post.nested_attributes.each do |nested_attribute| + nested_metadata = post[nested_attribute] + # @todo find_or_initialize + nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested) + nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash + + # Completa la relación 1:1 + nested_params[nested_metadata.inverse.to_s] = post.uuid.value + post[nested_attribute].value = nested_post.uuid.value + + files << nested_post.path.absolute if nested_post.update(nested_params) + end + end end diff --git a/app/views/layouts/_details.haml b/app/views/layouts/_details.haml index a21f46c1..9f8a4658 100644 --- a/app/views/layouts/_details.haml +++ b/app/views/layouts/_details.haml @@ -7,8 +7,9 @@ @param :summary_class [String] Clases para el summary - local_assigns[:summary_class] ||= 'h3' +- local_assigns[:details_class] ||= 'py-2' -%details.details.py-2{ id: local_assigns[:id], data: { controller: 'details', action: 'toggle->details#store' } } +%details.details{ id: local_assigns[:id], class: local_assigns[:details_class], data: { controller: 'details', action: 'toggle->details#store' } } %summary.d-flex.flex-row.align-items-center.justify-content-between{ class: local_assigns[:summary_class] } %span= summary %span.hide-when-open ▶ diff --git a/app/views/posts/_attributes.haml b/app/views/posts/_attributes.haml new file mode 100644 index 00000000..ed958d08 --- /dev/null +++ b/app/views/posts/_attributes.haml @@ -0,0 +1,16 @@ +-# + @param base [String] + @param locale [String] + @param post [Post] + @param site [Site] + @param dir [String] +- post.attributes.each do |attribute| + - metadata = post[attribute] + - type = metadata.type + + - cache [metadata, I18n.locale] do + = render("posts/attributes/#{type}", + base: base, post: post, attribute: attribute, + metadata: metadata, site: site, + dir: dir, locale: locale, + autofocus: (post.attributes.first == attribute)) diff --git a/app/views/posts/_attributes_nested.haml b/app/views/posts/_attributes_nested.haml new file mode 100644 index 00000000..83bcc51c --- /dev/null +++ b/app/views/posts/_attributes_nested.haml @@ -0,0 +1,18 @@ +-# + @param inverse [Symbol] + @param base [String] + @param locale [String] + @param post [Post] + @param site [Site] + @param dir [String] +- post.attributes.each do |attribute| + - next if attribute == :date + - next if attribute == :draft + - next if attribute == inverse + - metadata = post[attribute] + + - cache [post, metadata, I18n.locale] do + = render "posts/attributes/#{metadata.type}", + base: base, post: post, attribute: attribute, + metadata: metadata, site: site, + dir: dir, locale: locale, autofocus: false diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml index 7de0ea79..92bee939 100644 --- a/app/views/posts/_form.haml +++ b/app/views/posts/_form.haml @@ -41,16 +41,7 @@ = hidden_field_tag 'post[layout]', post.layout.name -# Dibuja cada atributo - - post.attributes.each do |attribute| - - metadata = post[attribute] - - type = metadata.type - - - cache [metadata, I18n.locale] do - = render("posts/attributes/#{type}", - base: 'post', post: post, attribute: attribute, - metadata: metadata, site: site, - dir: dir, locale: @locale, - autofocus: (post.attributes.first == attribute)) + = render 'posts/attributes', site: site, post: post, dir: dir, base: 'post', locale: @locale -# Botones de guardado = render 'posts/submit', site: site, post: post diff --git a/app/views/posts/_table.haml b/app/views/posts/_table.haml new file mode 100644 index 00000000..ff35aace --- /dev/null +++ b/app/views/posts/_table.haml @@ -0,0 +1,34 @@ +-# + Muestra una tabla con todos los atributos de un post + + @param site [Site] + @param locale [Symbol] + @param dir [String] + @param post [Post] + @param title [String] + +%table.table.table-condensed + %thead + %tr + %th.text-center{ colspan: 2 }= title + %tbody + - post.attributes.each do |attr| + - metadata = post[attr] + - next unless metadata.front_matter? + + - cache [post, metadata, I18n.locale] do + = render("posts/attribute_ro/#{metadata.type}", + post: post, attribute: attr, + metadata: metadata, + site: site, + locale: locale, + dir: dir) + +-# Mostrar todo lo que no va en el front_matter (el contenido) +- post.attributes.each do |attr| + - metadata = post[attr] + - next if metadata.front_matter? + + - cache [post, metadata, I18n.locale] do + %section.content.pb-3{ id: attr, dir: dir } + = metadata.to_s.html_safe diff --git a/app/views/posts/attribute_ro/_has_one_nested.haml b/app/views/posts/attribute_ro/_has_one_nested.haml new file mode 100644 index 00000000..1c89474e --- /dev/null +++ b/app/views/posts/attribute_ro/_has_one_nested.haml @@ -0,0 +1,6 @@ +%tr{ id: attribute } + %td{ dir: dir, lang: locale, colspan: 2 } + - if (p = metadata.has_one) + = render 'layouts/details', details_class: '', summary_class: 'font-weight-bold', summary: post_label_t(attribute, post: post) do + .mt-3 + = render 'posts/table', site: site, post: p, dir: dir, locale: locale, title: p.layout.humanized_name diff --git a/app/views/posts/attributes/_has_one_nested.haml b/app/views/posts/attributes/_has_one_nested.haml new file mode 100644 index 00000000..4aabf386 --- /dev/null +++ b/app/views/posts/attributes/_has_one_nested.haml @@ -0,0 +1,6 @@ +- nested_post = metadata.has_one || site.posts(lang: locale).build(layout: metadata.nested) +- base = "#{base}[#{metadata.name}]" + +.form-group + = render 'layouts/details', id: metadata.nested, summary: site.layouts[metadata.nested].humanized_name do + = render 'posts/attributes_nested', site: site, post: nested_post, dir: dir, base: base, locale: locale, inverse: metadata.inverse diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 10fe64e3..f44e11ed 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -1,4 +1,5 @@ - dir = @site.data.dig(params[:locale], 'dir') + .row.justify-content-center .col-md-8 %article.content.table-responsive-md @@ -6,28 +7,4 @@ edit_site_post_path(@site, @post.id), class: 'btn btn-secondary btn-block' - %table.table.table-condensed - %thead - %tr - %th.text-center{ colspan: 2 }= t('.front_matter') - %tbody - - @post.attributes.each do |attr| - - metadata = @post[attr] - - next unless metadata.front_matter? - - - cache [metadata, I18n.locale] do - = render("posts/attribute_ro/#{metadata.type}", - post: @post, attribute: attr, - metadata: metadata, - site: @site, - locale: @locale, - dir: dir) - - -# Mostrar todo lo que no va en el front_matter (el contenido) - - @post.attributes.each do |attr| - - metadata = @post[attr] - - next if metadata.front_matter? - - - cache [metadata, I18n.locale] do - %section.content.pb-3{ id: attr, dir: dir } - = @post.public_send(attr).to_s.html_safe + = render 'table', dir: dir, site: @site, locale: @locale, post: @post, title: t('.front_matter')