diff --git a/Gemfile b/Gemfile index 022275b8..ae53f421 100644 --- a/Gemfile +++ b/Gemfile @@ -87,6 +87,7 @@ gem 'after_commit_everywhere', '~> 1.0' gem 'aasm' gem 'que-web' gem 'nanoid' +gem 'nice_partials' # database gem 'hairtrigger' diff --git a/Gemfile.lock b/Gemfile.lock index 556f16e6..547fab74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -389,6 +389,8 @@ GEM net-protocol net-ssh (7.2.1) netaddr (2.0.6) + nice_partials (0.10.0) + actionview (>= 4.2.6) nio4r (2.7.0-x86_64-linux-musl) nokogiri (1.16.4-x86_64-linux-musl) mini_portile2 (~> 2.8.2) @@ -685,6 +687,7 @@ DEPENDENCIES mobility nanoid net-ssh + nice_partials nokogiri pg pg_search diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 117be995..0fc2440f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,7 +2,7 @@ # Forma de ingreso a Sutty class ApplicationController < ActionController::Base - include ExceptionHandler + include ExceptionHandler if Rails.env.production? include Pundit::Authorization protect_from_forgery with: :null_session, prepend: true @@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base session[:usuarie_return_to] = request.fullpath end + + # Detecta si una petición fue hecha por HTMX + def htmx? + request.headers.key? 'HX-Request' + end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index f241dfb1..ff3ecb8e 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -32,6 +32,33 @@ class PostsController < ApplicationController render layout: false end + def new_related_post + @uuid = params.require(:value).strip + + @indexed_post = site.indexed_posts.find_by!(post_id: @uuid) + + render layout: false + end + + # El formulario de un Post, si pasamos el uuid, estamos editando, sino + # estamos creando. + def form + uuid = params.permit(:uuid).try(:[], :uuid) + locale + + @post = + if uuid.present? + site.indexed_posts.find_by!(post_id: uuid).post + else + # @todo Usar la base de datos + site.posts(lang: locale).build(layout: params.require(:layout)) + end + + swap_modals + + render layout: false + end + def index authorize Post @@ -87,7 +114,23 @@ class PostsController < ApplicationController if @post.persisted? site.touch forget_content + end + # @todo Enviar la creación a otro endpoint para evitar tantas + # condiciones. + if htmx? + if @post.persisted? + swap_modals + + @value = @post.title.value + @uuid = @post.uuid.value + @name = params.require(:name) + + render 'posts/new_has_many_value', layout: false + else + # @todo Mostrar errores + end + elsif @post.persisted? redirect_to site_post_path(@site, @post) else render 'posts/new' @@ -185,4 +228,13 @@ class PostsController < ApplicationController def service_for_direct_upload session[:service_name] = site.name.to_sym end + + # @param triggers [Hash] Otros disparadores + def swap_modals(triggers = {}) + params.permit(:show, :hide).each_pair do |key, value| + triggers["modal:#{key}"] = { id: value } + end + + headers['HX-Trigger'] = triggers.to_json if triggers.present? + end end diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 3a614b80..20154f79 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -3,8 +3,30 @@ import { Controller } from "stimulus"; export default class extends Controller { static targets = ["modal", "backdrop"]; + // TODO: Stimulus >1 + connect() { + this.showEvent = this.show.bind(this); + this.hideEvent = this.hide.bind(this); + + window.addEventListener("modal:show", this.showEvent); + window.addEventListener("modal:hide", this.hideEvent); + } + + // TODO: Stimulus >1 + disconnect() { + window.removeEventListener("modal:show", this.showEvent); + window.removeEventListener("modal:hide", this.hideEvent); + } + + /* + * Podemos enviar la orden de apertura como un click o como un + * CustomEvent incluyendo el id del modal como detail. + */ show(event = undefined) { event?.preventDefault(); + const modalId = event?.detail?.id; + + if (modalId && this.element.id !== modalId) return; this.modalTarget.style.display = "block"; this.backdropTarget.style.display = "block"; @@ -22,6 +44,9 @@ export default class extends Controller { hide(event = undefined) { event?.preventDefault(); + const modalId = event?.detail?.id; + + if (modalId && this.element.id !== modalId) return; this.backdropTarget.classList.remove("show"); this.modalTarget.classList.remove("show"); diff --git a/app/models/metadata_new_has_many.rb b/app/models/metadata_new_has_many.rb new file mode 100644 index 00000000..3bf37d45 --- /dev/null +++ b/app/models/metadata_new_has_many.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Interfaz nueva para uno a muchos +class MetadataNewHasMany < MetadataHasMany; end diff --git a/app/models/metadata_new_predefined_array.rb b/app/models/metadata_new_predefined_array.rb new file mode 100644 index 00000000..8c15155f --- /dev/null +++ b/app/models/metadata_new_predefined_array.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Nueva interfaz para arrays predefinidos +class MetadataNewPredefinedArray < MetadataPredefinedArray; end diff --git a/app/models/metadata_related_posts.rb b/app/models/metadata_related_posts.rb index 42d1381b..2728020e 100644 --- a/app/models/metadata_related_posts.rb +++ b/app/models/metadata_related_posts.rb @@ -26,6 +26,13 @@ class MetadataRelatedPosts < MetadataArray posts.where(uuid: value).map(&:title).map(&:value) end + # Encuentra el filtro + # + # @return [Hash] + def filter + layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {} + end + private # Obtiene todos los posts y opcionalmente los filtra @@ -37,11 +44,6 @@ class MetadataRelatedPosts < MetadataArray "#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})" end - # Encuentra el filtro - def filter - layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {} - end - def sanitize(uuid) super(uuid.map do |u| u.to_s.gsub(/[^a-f0-9\-]/i, '') diff --git a/app/services/post_service.rb b/app/services/post_service.rb index ce6b1b26..c851e6eb 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -15,7 +15,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do post.draft.value = true if site.invitade? usuarie post.assign_attributes(post_params) - params.require(:post).permit(:slug).tap do |p| + params.require(base).permit(:slug).tap do |p| post.slug.value = p[:slug] if p[:slug].present? end @@ -87,7 +87,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # # { uuid => 2, uuid => 1, uuid => 0 } def reorder - reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i) + reorder = params.require(base).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i) posts = site.posts(lang: locale).where(uuid: reorder.keys) files = posts.map do |post| @@ -110,6 +110,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do private + # La base donde buscar los parámetros + # + # @return [Symbol] + def base + @base ||= params.permit(:base).try(:[], :base).try(:to_sym) || :post + end + # Una lista de archivos a modificar # # @return [Set] @@ -131,7 +138,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # Solo permitir cambiar estos atributos de cada articulo def post_params - @post_params ||= params.require(:post).permit(post.params).to_h + @post_params ||= params.require(base).permit(post.params).to_h end # Eliminar metadatos internos diff --git a/app/views/posts/_htmx_form.haml b/app/views/posts/_htmx_form.haml new file mode 100644 index 00000000..25c45aad --- /dev/null +++ b/app/views/posts/_htmx_form.haml @@ -0,0 +1,41 @@ +-# + El formulario del artículo, con HTMX activado. + + @param :site [Site] + @param :post [Post] + @param :locale [Symbol, String] + @param :dir [Symbol, String] +:ruby + options = { + multipart: true, + class: 'form post ', + 'hx-swap': params.require(:swap), + 'hx-target': "##{params.require(:target)}" + } + + if post.new? + url = options[:'hx-post'] = site_posts_path(site, locale: locale) + options[:class] += 'new' + else + url = options[:'hx-patch'] = site_post_path(site, post.id, locale: locale) + options[:method] = :patch + options[:class] += 'edit' + end + += form_tag url, **options do + -# Parámetros para HTMX + %input{ type: 'hidden', name: 'hide', value: params.require(:show) } + %input{ type: 'hidden', name: 'show', value: params.require(:hide) } + %input{ type: 'hidden', name: 'name', value: params.require(:name) } + %input{ type: 'hidden', name: 'base', value: params.require(:base) } + + -# Botones de guardado + = render 'posts/submit', site: site, post: post + + = hidden_field_tag "#{base}[layout]", post.layout.name + + -# Dibuja cada atributo + = render 'posts/attributes', site: site, post: post, dir: dir, base: base, locale: locale + + -# Botones de guardado + = render 'posts/submit', site: site, post: post diff --git a/app/views/posts/_new_related_post.haml b/app/views/posts/_new_related_post.haml new file mode 100644 index 00000000..f3cc2a94 --- /dev/null +++ b/app/views/posts/_new_related_post.haml @@ -0,0 +1,2 @@ +.col + %p= link_to post.title, post.path diff --git a/app/views/posts/attribute_ro/_new_has_many.haml b/app/views/posts/attribute_ro/_new_has_many.haml new file mode 100644 index 00000000..d6b51a7a --- /dev/null +++ b/app/views/posts/attribute_ro/_new_has_many.haml @@ -0,0 +1,6 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td + %ul{ dir: dir, lang: locale } + - metadata.has_many.each do |p| + %li= link_to p.title.value, site_post_path(site, p.id) diff --git a/app/views/posts/attribute_ro/_new_predefined_array.haml b/app/views/posts/attribute_ro/_new_predefined_array.haml new file mode 100644 index 00000000..88a82626 --- /dev/null +++ b/app/views/posts/attribute_ro/_new_predefined_array.haml @@ -0,0 +1,5 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td + - metadata.value.each do |v| + %span.badge.badge-primary{ dir: dir, lang: locale }= metadata.values.key v diff --git a/app/views/posts/attributes/_new_has_many.haml b/app/views/posts/attributes/_new_has_many.haml new file mode 100644 index 00000000..16d68322 --- /dev/null +++ b/app/views/posts/attributes/_new_has_many.haml @@ -0,0 +1,92 @@ +-# + Genera un listado de checkboxes entre los que se puede elegir para + guardar. Podemos elegir entre los artículos ya cargados o agregar uno + nuevo. + + Al agregar uno nuevo, se abre un segundo modal que carga el formulario + correspondiente vía HTMX. El formulario tiene que cargarse por fuera + del formulario principal porque no se pueden anidar. + +:ruby + id = "#{base}_#{attribute}" + name = "#{base}[#{attribute}][]" + form_id = "form-#{Nanoid.generate}" + modal_id = "modal-#{Nanoid.generate}" + post_id = "post-#{Nanoid.generate}" + post_form_id = "post-form-#{Nanoid.generate}" + post_modal_id = "post-modal-#{Nanoid.generate}" + post_form_loaded_id = "post-loaded-#{Nanoid.generate}" + value_list_id = "#{id}_body" + +%div{ id: modal_id, data: { controller: 'modal array', 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } } + .form-group + = hidden_field_tag name, '' + = label_tag id, post_label_t(attribute, post: post) + -# Mostramos la lista de valores actuales. + + Al aceptar el modal, se vacía el listado y se completa en base a + renderizaciones con HTMX. Para poder hacer eso, tenemos que poder + acceder a todos los items dentro del modal (como array.item) y + enviar el valor al endpoint que devuelve uno por uno. Esto lo + tenemos disponible en Stimulus, pero queremos usar HTMX o técnica + similar para poder renderizar del lado del servidor. + + Para poder cancelar, mantenemos el estado original y desactivamos + o activamos los ítemes según estén incluidos en esa lista o no. + .row.row-cols-1.row-cols-md-2{ data: { target: 'array.current' } } + - metadata.values.slice(*metadata.value).each do |value| + = render 'posts/new_array_value', value: value + + = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show' + + = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'] do + - content_for :"#{id}_header" do + .form-group.flex-grow-1.mb-0 + = label_tag id, post_label_t(attribute, post: post) + %input.form-control{ data: { target: 'array.search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') } + + - content_for :"#{id}_body" do + .form-group.mb-0{ id: value_list_id } + - metadata.values.each_pair do |value, uuid| + .mb-2{ data: { target: 'array.item', 'searchable-value': value.remove_diacritics.downcase, value: uuid } } + = render 'bootstrap/custom_checkbox', name: name, id: "value-#{Nanoid.generate}", value: uuid, checked: metadata.value.include?(uuid), content: value + + -# + Según la definición del campo, si hay un filtro, tenemos que poder + elegir qué tipo de esquema queremos o si hay uno solo, siempre + vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir + entre todos los esquemas. + + - content_for :"#{id}_footer" do + - layout = metadata.filter[:layout] + - if layout.is_a?(String) + %input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id } + = render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1' + - else + - layouts = layout&.map { |x| site.layouts[x] } + - layouts ||= site.layouts.values + .input-group.w-auto.flex-grow-1.my-0 + %select.form-control{ form: post_form_id, name: 'layout' } + - layouts.each do |layout| + %option{ value: layout.name }= layout.humanized_name + .input-group-append + = render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0' + = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1' + = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0' + +-# + Este segundo modal es el que carga los formularios de + creación/modificación de artículos relacionados. Se envía a post_form + para que sea externo al formulario actual. +- content_for :post_form do + %form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" } + %input{ type: 'hidden', name: 'show', value: post_modal_id } + %input{ type: 'hidden', name: 'hide', value: modal_id } + %input{ type: 'hidden', name: 'target', value: value_list_id } + %input{ type: 'hidden', name: 'swap', value: 'beforeend' } + %input{ type: 'hidden', name: 'base', value: id } + %input{ type: 'hidden', name: 'name', value: name } + %div{ id: post_modal_id, data: { controller: 'modal' } } + = render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do + - content_for :"#{post_id}_body" do + %div{ id: post_form_loaded_id } diff --git a/app/views/posts/attributes/_new_predefined_array.haml b/app/views/posts/attributes/_new_predefined_array.haml new file mode 100644 index 00000000..5203c12b --- /dev/null +++ b/app/views/posts/attributes/_new_predefined_array.haml @@ -0,0 +1,48 @@ +-# + Genera un listado de checkboxes entre los que se puede elegir para + guardar, pero no se pueden agregar nuevos. + +:ruby + id = "#{base}_#{attribute}" + name = "#{base}[#{attribute}][]" + form_id = "form-#{Nanoid.generate}" + +%div{ data: { controller: 'modal array', 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } } + .form-group + = hidden_field_tag name, '' + = label_tag id, post_label_t(attribute, post: post) + -# Mostramos la lista de valores actuales. + + Al aceptar el modal, se vacía el listado y se completa en base a + renderizaciones con HTMX. Para poder hacer eso, tenemos que poder + acceder a todos los items dentro del modal (como array.item) y + enviar el valor al endpoint que devuelve uno por uno. Esto lo + tenemos disponible en Stimulus, pero queremos usar HTMX o técnica + similar para poder renderizar del lado del servidor. + + Para poder cancelar, mantenemos el estado original y desactivamos + o activamos los ítemes según estén incluidos en esa lista o no. + .row.row-cols-1.row-cols-md-2{ data: { target: 'array.current' } } + - metadata.values.slice(*metadata.value).each_key do |value| + = render 'posts/new_array_value', value: value + + = render 'bootstrap/btn', content: t('.edit'), action: 'modal#show' + + = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'] do + - content_for :"#{id}_header" do + .form-group.flex-grow-1.mb-0 + = label_tag id, post_label_t(attribute, post: post) + %input.form-control{ data: { target: 'array.search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') } + + - content_for :"#{id}_body" do + .form-group.mb-0{ id: "#{id}_body" } + -# Eliminamos las tildes para poder buscar independientemente de cómo se escriba + - metadata.values.each_pair do |value, key| + .mb-2{ data: { target: 'array.item', 'searchable-value': value.remove_diacritics.downcase, value: value } } + = render 'bootstrap/custom_checkbox', name: name, id: "value-#{Nanoid.generate}", value: key, checked: metadata.value.include?(key), content: value + + - content_for :"#{id}_footer" do + -# Alinear los botones a la derecha + .flex-grow-1 + = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1' + = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0' diff --git a/app/views/posts/form.haml b/app/views/posts/form.haml new file mode 100644 index 00000000..df4369e1 --- /dev/null +++ b/app/views/posts/form.haml @@ -0,0 +1,7 @@ +-# + El formulario sin ninguna decoración, para incluir dentro de otros + elementos. + + @param :site [Site] + @param :post [Post] += render 'posts/htmx_form', site: @site, post: @post, locale: @locale, dir: t("locales.#{@locale}.dir"), base: params.require(:base) diff --git a/app/views/posts/new_has_many_value.haml b/app/views/posts/new_has_many_value.haml new file mode 100644 index 00000000..b26acc89 --- /dev/null +++ b/app/views/posts/new_has_many_value.haml @@ -0,0 +1,2 @@ +.mb-2{ data: { target: 'array.item', 'searchable-value': @value.remove_diacritics.downcase, value: @uuid } } + = render 'bootstrap/custom_checkbox', name: @name, id: "value-#{Nanoid.generate}", value: @uuid, checked: true, content: @value diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index 6419a138..3bc1477f 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -4,7 +4,7 @@ %p.lead= t('.help') - if policy(Site).new? = link_to t('sites.new.title'), new_site_path, - class: 'btn btn-secondary' + class: 'btn btn-secondary' %section.col - if @sites.empty? @@ -15,11 +15,14 @@ %tbody - @sites.each do |site| - next unless site.jekyll? + %tr %td %h2 - if policy(site).show? - = link_to site.title, site_posts_path(site, locale: site.default_locale) + = link_to site.title, + site_posts_path(site, + locale: site.default_locale) - else = site.title %p.lead= site.description @@ -28,27 +31,27 @@ = link_to t('.visit'), site.url, class: 'btn btn-secondary' - if current_usuarie.rol_for_site(site).temporal? = render 'components/btn_base', - text: t('sites.invitations.accept'), - path: site_usuaries_accept_invitation_path(site), - title: t('help.sites.invitations.accept'), - class: 'btn-secondary' + text: t('sites.invitations.accept'), + path: site_usuaries_accept_invitation_path(site), + title: t('help.sites.invitations.accept'), + class: 'btn-secondary' = render 'components/btn_base', - text: t('sites.invitations.reject'), - path: site_usuaries_reject_invitation_path(site), - title: t('help.sites.invitations.reject'), - class: 'btn-secondary' + text: t('sites.invitations.reject'), + path: site_usuaries_reject_invitation_path(site), + title: t('help.sites.invitations.reject'), + class: 'btn-secondary' - else - if policy(site).show? = render 'layouts/btn_with_tooltip', - tooltip: t('help.sites.edit_posts'), - type: 'success', - link: site_path(site), - text: t('sites.posts') + tooltip: t('help.sites.edit_posts'), + type: 'success', + link: site_path(site), + text: t('sites.posts') = render 'sites/build', site: site = render 'sites/moderation_queue', site: site - if policy(SiteUsuarie.new(site, current_usuarie)).index? = render 'layouts/btn_with_tooltip', - tooltip: t('usuaries.index.help.self'), - text: t('usuaries.index.title'), - type: 'info', - link: site_usuaries_path(site) + tooltip: t('usuaries.index.help.self'), + text: t('usuaries.index.title'), + type: 'info', + link: site_usuaries_path(site) diff --git a/config/locales/en.yml b/config/locales/en.yml index 9b367f6c..56fe8919 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -720,6 +720,17 @@ en: new_array: remove: "Remove" attributes: + new_has_many: + edit: "Edit" + filter: "Start typing to filter..." + accept: "Accept" + cancel: "Cancel" + add: "Add %{layout}" + new_predefined_array: + edit: "Edit" + filter: "Start typing to filter..." + accept: "Accept" + cancel: "Cancel" new_array: edit: "Edit" filter: "Start typing to filter..." diff --git a/config/locales/es.yml b/config/locales/es.yml index 921ec3f1..13b2e89d 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -728,6 +728,17 @@ es: new_array: remove: "Eliminar" attributes: + new_has_many: + edit: "Editar" + filter: "Empezá a escribir para filtrar..." + accept: "Aceptar" + cancel: "Cancelar" + add: "Agregar %{layout}" + new_predefined_array: + edit: "Editar" + filter: "Empezá a escribir para filtrar..." + accept: "Aceptar" + cancel: "Cancelar" new_array: edit: "Editar" filter: "Empezá a escribir para filtrar..." diff --git a/config/routes.rb b/config/routes.rb index eb20edce..45410782 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,8 @@ Rails.application.routes.draw do get :'posts/new_array', to: 'posts#new_array' get :'posts/new_array_value', to: 'posts#new_array_value' + get :'posts/new_related_post', to: 'posts#new_related_post' + get :'posts/form', to: 'posts#form' resources :posts do get 'p/:page', action: :index, on: :collection