5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-24 10:26:22 +00:00

Merge branch 'issue-15068' into production.panel.sutty.nl

This commit is contained in:
f 2024-05-20 14:33:19 -03:00
commit 277b03eab1
No known key found for this signature in database
21 changed files with 360 additions and 27 deletions

View file

@ -87,6 +87,7 @@ gem 'after_commit_everywhere', '~> 1.0'
gem 'aasm' gem 'aasm'
gem 'que-web' gem 'que-web'
gem 'nanoid' gem 'nanoid'
gem 'nice_partials'
# database # database
gem 'hairtrigger' gem 'hairtrigger'

View file

@ -389,6 +389,8 @@ GEM
net-protocol net-protocol
net-ssh (7.2.1) net-ssh (7.2.1)
netaddr (2.0.6) netaddr (2.0.6)
nice_partials (0.10.0)
actionview (>= 4.2.6)
nio4r (2.7.0-x86_64-linux-musl) nio4r (2.7.0-x86_64-linux-musl)
nokogiri (1.16.4-x86_64-linux-musl) nokogiri (1.16.4-x86_64-linux-musl)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
@ -685,6 +687,7 @@ DEPENDENCIES
mobility mobility
nanoid nanoid
net-ssh net-ssh
nice_partials
nokogiri nokogiri
pg pg
pg_search pg_search

View file

@ -2,7 +2,7 @@
# Forma de ingreso a Sutty # Forma de ingreso a Sutty
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include ExceptionHandler include ExceptionHandler if Rails.env.production?
include Pundit::Authorization include Pundit::Authorization
protect_from_forgery with: :null_session, prepend: true protect_from_forgery with: :null_session, prepend: true
@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
session[:usuarie_return_to] = request.fullpath session[:usuarie_return_to] = request.fullpath
end end
# Detecta si una petición fue hecha por HTMX
def htmx?
request.headers.key? 'HX-Request'
end
end end

View file

@ -32,6 +32,33 @@ class PostsController < ApplicationController
render layout: false render layout: false
end 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 def index
authorize Post authorize Post
@ -87,7 +114,23 @@ class PostsController < ApplicationController
if @post.persisted? if @post.persisted?
site.touch site.touch
forget_content 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) redirect_to site_post_path(@site, @post)
else else
render 'posts/new' render 'posts/new'
@ -185,4 +228,13 @@ class PostsController < ApplicationController
def service_for_direct_upload def service_for_direct_upload
session[:service_name] = site.name.to_sym session[:service_name] = site.name.to_sym
end 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 end

View file

@ -3,8 +3,30 @@ import { Controller } from "stimulus";
export default class extends Controller { export default class extends Controller {
static targets = ["modal", "backdrop"]; 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) { show(event = undefined) {
event?.preventDefault(); event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.modalTarget.style.display = "block"; this.modalTarget.style.display = "block";
this.backdropTarget.style.display = "block"; this.backdropTarget.style.display = "block";
@ -22,6 +44,9 @@ export default class extends Controller {
hide(event = undefined) { hide(event = undefined) {
event?.preventDefault(); event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.backdropTarget.classList.remove("show"); this.backdropTarget.classList.remove("show");
this.modalTarget.classList.remove("show"); this.modalTarget.classList.remove("show");

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Interfaz nueva para uno a muchos
class MetadataNewHasMany < MetadataHasMany; end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para arrays predefinidos
class MetadataNewPredefinedArray < MetadataPredefinedArray; end

View file

@ -26,6 +26,13 @@ class MetadataRelatedPosts < MetadataArray
posts.where(uuid: value).map(&:title).map(&:value) posts.where(uuid: value).map(&:title).map(&:value)
end end
# Encuentra el filtro
#
# @return [Hash]
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end
private private
# Obtiene todos los posts y opcionalmente los filtra # 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})" "#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})"
end end
# Encuentra el filtro
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end
def sanitize(uuid) def sanitize(uuid)
super(uuid.map do |u| super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '') u.to_s.gsub(/[^a-f0-9\-]/i, '')

View file

@ -15,7 +15,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.draft.value = true if site.invitade? usuarie post.draft.value = true if site.invitade? usuarie
post.assign_attributes(post_params) 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? post.slug.value = p[:slug] if p[:slug].present?
end end
@ -87,7 +87,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# #
# { uuid => 2, uuid => 1, uuid => 0 } # { uuid => 2, uuid => 1, uuid => 0 }
def reorder 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) posts = site.posts(lang: locale).where(uuid: reorder.keys)
files = posts.map do |post| files = posts.map do |post|
@ -110,6 +110,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
private 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 # Una lista de archivos a modificar
# #
# @return [Set] # @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 # Solo permitir cambiar estos atributos de cada articulo
def post_params def post_params
@post_params ||= params.require(:post).permit(post.params).to_h @post_params ||= params.require(base).permit(post.params).to_h
end end
# Eliminar metadatos internos # Eliminar metadatos internos

View file

@ -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

View file

@ -0,0 +1,2 @@
.col
%p= link_to post.title, post.path

View file

@ -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)

View file

@ -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

View file

@ -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 }

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -15,11 +15,14 @@
%tbody %tbody
- @sites.each do |site| - @sites.each do |site|
- next unless site.jekyll? - next unless site.jekyll?
%tr %tr
%td %td
%h2 %h2
- if policy(site).show? - 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 - else
= site.title = site.title
%p.lead= site.description %p.lead= site.description

View file

@ -720,6 +720,17 @@ en:
new_array: new_array:
remove: "Remove" remove: "Remove"
attributes: 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: new_array:
edit: "Edit" edit: "Edit"
filter: "Start typing to filter..." filter: "Start typing to filter..."

View file

@ -728,6 +728,17 @@ es:
new_array: new_array:
remove: "Eliminar" remove: "Eliminar"
attributes: 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: new_array:
edit: "Editar" edit: "Editar"
filter: "Empezá a escribir para filtrar..." filter: "Empezá a escribir para filtrar..."

View file

@ -102,6 +102,8 @@ Rails.application.routes.draw do
get :'posts/new_array', to: 'posts#new_array' get :'posts/new_array', to: 'posts#new_array'
get :'posts/new_array_value', to: 'posts#new_array_value' 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 resources :posts do
get 'p/:page', action: :index, on: :collection get 'p/:page', action: :index, on: :collection