5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2025-03-14 23:08:17 +00:00

Merge branch 'issue-15068' into 'issue-15066'

Draft: new array #15068

See merge request sutty/sutty!263
This commit is contained in:
fauno 2024-10-25 04:42:37 +00:00
commit 01d8de0e97
129 changed files with 2347 additions and 303 deletions

View file

@ -79,6 +79,7 @@ gem 'webpacker'
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git' gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
gem 'kaminari' gem 'kaminari'
gem 'device_detector' gem 'device_detector'
gem 'htmlbeautifier'
gem 'dry-schema' gem 'dry-schema'
gem 'rubanok' gem 'rubanok'

View file

@ -269,6 +269,7 @@ GEM
hiredis (0.6.3-x86_64-linux-musl) hiredis (0.6.3-x86_64-linux-musl)
hiredis-client (0.14.1-x86_64-linux-musl) hiredis-client (0.14.1-x86_64-linux-musl)
redis-client (= 0.14.1) redis-client (= 0.14.1)
htmlbeautifier (1.4.2)
http_parser.rb (0.8.0-x86_64-linux-musl) http_parser.rb (0.8.0-x86_64-linux-musl)
httparty (0.21.0) httparty (0.21.0)
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
@ -659,6 +660,7 @@ DEPENDENCIES
hamlit-rails hamlit-rails
hiredis hiredis
hiredis-client hiredis-client
htmlbeautifier
httparty httparty
icalendar icalendar
image_processing image_processing

View file

@ -11,6 +11,21 @@ $colors: (
"magenta": $magenta "magenta": $magenta
); );
// TODO: Encontrar la forma de generar esto desde los locales de Rails
$custom-file-text: (
en: "Browse",
es: "Buscar archivo",
pt: "Buscar ficheiro",
pt-BR: "Buscar arquivo"
);
$custom-file-text-replace: (
en: "Replace file",
es: "Reemplazar archivo",
pt: "substituir ficheiro",
pt-BR: "substituir arquivo"
);
// Redefinir variables de Bootstrap // Redefinir variables de Bootstrap
$primary: $magenta; $primary: $magenta;
$secondary: $black; $secondary: $black;
@ -20,6 +35,17 @@ $form-feedback-valid-color: $black;
$form-feedback-invalid-color: $magenta; $form-feedback-invalid-color: $magenta;
$form-feedback-icon-valid-color: $black; $form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta; $component-active-bg: $magenta;
$zindex-modal-backdrop: 0;
$modal-content-bg: var(--background);
$modal-content-border-color: var(--modal-content-border-color);
$card-bg: var(--background);
$card-border-color: var(--card-border-color);
$input-bg: var(--background);
$input-color: var(--foreground);
$btn-bg-color: var(--btn-bg-color);
$btn-color: var(--btn-color);
$input-group-addon-bg: var(--btn-bg-color);
$custom-file-color: var(--btn-color);
$spacers: ( $spacers: (
2-plus: 0.75rem 2-plus: 0.75rem
@ -32,6 +58,16 @@ $sizes: (
@import "bootstrap"; @import "bootstrap";
@import "editor"; @import "editor";
.custom-file-input {
&.replace-image {
@each $lang, $value in $custom-file-text-replace {
&:lang(#{$lang}) ~ .custom-file-label::after {
content: $value;
}
}
}
}
@each $color, $rgb in $theme-colors { @each $color, $rgb in $theme-colors {
.#{$color} { .#{$color} {
color: var(--#{$color}); color: var(--#{$color});
@ -60,6 +96,8 @@ $sizes: (
--foreground: #{$black}; --foreground: #{$black};
--background: #{$white}; --background: #{$white};
--color: #{$magenta}; --color: #{$magenta};
--card-border-color: #{rgba($black, .125)};
--modal-content-border-color: rgba(#{$black}, .2);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -67,6 +105,8 @@ $sizes: (
--foreground: #{$white}; --foreground: #{$white};
--background: #{$black}; --background: #{$black};
--color: #{$cyan}; --color: #{$cyan};
--card-border-color: #{rgba($white, .125)};
--modal-content-border-color: #{rgba($white, .2)};
} }
.btn-secondary { .btn-secondary {
@ -87,13 +127,17 @@ $sizes: (
box-shadow: 0 0 0 0.2rem $cyan; box-shadow: 0 0 0 0.2rem $cyan;
} }
} }
}
// TODO: Encontrar la forma de generar esto desde los locales de Rails @include form-validation-state("valid", $cyan, url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$white}' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>"));
$custom-file-text: (
en: 'Browse', .custom-checkbox {
es: 'Buscar archivo' .custom-control-input:checked ~ .custom-control-label {
); &::after {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$black}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/></svg>");
}
}
}
}
@font-face { @font-face {
font-family: 'Saira'; font-family: 'Saira';
@ -318,10 +362,6 @@ svg {
} }
} }
.custom-control-label {
font-weight: bold;
}
.designs { .designs {
.design { .design {
margin-top: 1rem; margin-top: 1rem;
@ -621,3 +661,33 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
} }
} }
} }
// https://getbootstrap.com/docs/5.1/components/placeholders/
.placeholder {
display: inline-block;
min-height: $spacer;
cursor: wait;
vertical-align: middle;
opacity: .5;
background-color: $grey;
animation: placeholder-glow 2s ease-in-out infinite;
}
.placeholder-glow {
.placeholder {
-webkit-animation: placeholder-glow 2s ease-in-out infinite;
animation: placeholder-glow 2s ease-in-out infinite;
}
@-webkit-keyframes placeholder-glow {
50% {
opacity: .2;
}
}
@keyframes placeholder-glow {
50% {
opacity: .2;
}
}
}

View file

@ -6,6 +6,7 @@ $cyan: #13fefe;
--foreground: #{$white}; --foreground: #{$white};
--background: #{$black}; --background: #{$black};
--color: #{$cyan}; --color: #{$cyan};
--card-border-color: #{rgba($white, .125)};
} }
.btn { .btn {

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

@ -5,9 +5,10 @@
# No necesitamos autenticación aun # No necesitamos autenticación aun
class CollaborationsController < ApplicationController class CollaborationsController < ApplicationController
include Pundit include Pundit
include StrongParamsHelper
def collaborate def collaborate
@site = Site.find_by_name(params[:site_id]) @site = Site.find_by_name(pluck_param(:site_id))
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie || @site.usuaries.build @invitade = current_usuarie || @site.usuaries.build
@ -21,7 +22,7 @@ class CollaborationsController < ApplicationController
# #
# * Si le usuarie existe y no está logueade, pedirle la contraseña # * Si le usuarie existe y no está logueade, pedirle la contraseña
def accept_collaboration def accept_collaboration
@site = Site.find_by_name(params[:site_id]) @site = Site.find_by_name(pluck_param(:site_id))
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie @invitade = current_usuarie

View file

@ -2,6 +2,8 @@
# Controlador para artículos # Controlador para artículos
class PostsController < ApplicationController class PostsController < ApplicationController
include StrongParamsHelper
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
before_action :service_for_direct_upload, only: %i[new edit] before_action :service_for_direct_upload, only: %i[new edit]
@ -15,6 +17,82 @@ class PostsController < ApplicationController
{ locale: locale } { locale: locale }
end end
# @todo Mover a tu propio scope
def new_array
@value = pluck_param(:value)
@name = pluck_param(:name)
id = pluck_param(:id)
headers['HX-Trigger-After-Swap'] = 'htmx:resetForm'
render layout: false
end
def new_array_value
@value = pluck_param(:value)
render layout: false
end
def new_related_post
@uuid = pluck_param(:value)
@indexed_post = site.indexed_posts.find_by!(post_id: @uuid)
render layout: false
end
def new_has_one
@uuid = pluck_param(:value)
@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 = pluck_param(:uuid, optional: true)
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: pluck_param(:layout))
end
swap_modals
render layout: false
end
# Genera un modal completo
#
# @todo recibir el atributo anterior
# @param :uuid [String] UUID del post (opcional)
# @param :layout [String] El layout a cargar (opcional)
def modal
uuid = pluck_param(:uuid, optional: true)
locale
# @todo hacer que si el uuid no existe se genera un post, para poder
# pasar el uuid sabiendolo
@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: pluck_param(:layout))
end
swap_modals
render layout: false
end
def index def index
authorize Post authorize Post
@ -55,7 +133,7 @@ class PostsController < ApplicationController
def new def new
authorize Post authorize Post
@post = site.posts(lang: locale).build(layout: params[:layout]) @post = site.posts(lang: locale).build(layout: pluck_param(:layout))
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), '' breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
end end
@ -65,13 +143,34 @@ class PostsController < ApplicationController
service = PostService.new(site: site, service = PostService.new(site: site,
usuarie: current_usuarie, usuarie: current_usuarie,
params: params) params: params)
@post = service.create @post = service.create_or_update
if @post.persisted? if post.persisted?
site.touch site.touch
forget_content forget_content
end
redirect_to site_post_path(@site, @post) # @todo Enviar la creación a otro endpoint para evitar tantas
# condiciones.
if htmx?
if post.persisted?
triggers = { 'notification:show' => { 'id' => pluck_param(:saved, optional: true) } }
swap_modals(triggers)
@value = post.title.value
@uuid = post.uuid.value
@name = pluck_param(:name)
render render_path_from_attribute, layout: false
else
headers['HX-Retarget'] = "##{pluck_param(:form)}"
headers['HX-Reswap'] = 'outerHTML'
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
end
elsif post.persisted?
redirect_to site_post_path(site, post)
else else
render 'posts/new' render 'posts/new'
end end
@ -83,6 +182,16 @@ class PostsController < ApplicationController
breadcrumb 'posts.edit', '' breadcrumb 'posts.edit', ''
end end
# Este endpoint se encarga de actualizar el post. Si el post se edita
# desde el formulario principal, re-renderizamos el formulario si hay
# errores o enviamos a otro lado al guardar.
#
# Si los datos llegaron por HTMX, hay que regenerar el formulario
# y reemplazarlo en su modal (?) o responder con su tarjeta para
# reemplazarla donde sea que esté.
#
# @todo la re-renderización del formulario no es necesaria si tenemos
# validación client-side.
def update def update
authorize post authorize post
@ -94,7 +203,37 @@ class PostsController < ApplicationController
if service.update.persisted? if service.update.persisted?
site.touch site.touch
forget_content forget_content
end
if htmx?
if post.persisted?
triggers = { 'notification:show' => pluck_param(:saved, optional: true) }
swap_modals(triggers)
@value = post.title.value
@uuid = post.uuid.value
if (result_id = pluck_param(:result_id, optional: true))
headers['HX-Retarget'] = "##{result_id}"
headers['HX-Reswap'] = 'outerHTML'
@indexed_post = site.indexed_posts.find_by_post_id(post.uuid.value)
render 'posts/new_related_post', layout: false
# @todo Confirmar que esta ruta no esté transitada
else
@name = pluck_param(:name)
render render_path_from_attribute, layout: false
end
else
headers['HX-Retarget'] = "##{params.require(:form)}"
headers['HX-Reswap'] = 'outerHTML'
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
end
elsif post.persisted?
redirect_to site_post_path(site, post) redirect_to site_post_path(site, post)
else else
render 'posts/edit' render 'posts/edit'
@ -168,4 +307,24 @@ 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 } if value.present?
end
headers['HX-Trigger'] = triggers.to_json if triggers.present?
end
# @return [String]
def render_path_from_attribute
case pluck_param(:attribute)
when 'new_has_many' then 'posts/new_has_many_value'
when 'new_belongs_to' then 'posts/new_belongs_to_value'
when 'new_has_and_belongs_to_many' then 'posts/new_has_many_value'
when 'new_has_one' then 'posts/new_has_one_value'
else 'nothing'
end
end
end end

View file

@ -59,9 +59,6 @@ class StatsController < ApplicationController
.order('sum(value) desc') .order('sum(value) desc')
.sum(:value) .sum(:value)
.transform_values(&:to_i) .transform_values(&:to_i)
.transform_values do |v|
v * nodes
end
end end
end end
end end
@ -73,9 +70,6 @@ class StatsController < ApplicationController
stats = rollup_scope.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series| stats = rollup_scope.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series|
series.each do |serie| series.each do |serie|
serie[:name] = serie.dig(:dimensions, 'host') serie[:name] = serie.dig(:dimensions, 'host')
serie[:data].transform_values! do |value|
value * nodes
end
end end
end end
@ -99,9 +93,6 @@ class StatsController < ApplicationController
stats = rollup_scope.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series| stats = rollup_scope.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series|
series.each do |serie| series.each do |serie|
serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/') serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/')
serie[:data].transform_values! do |value|
value * nodes
end
end end
end end
@ -197,21 +188,6 @@ class StatsController < ApplicationController
end end
end end
# Obtiene la cantidad de nodos de Sutty, para poder calcular la
# cantidad de visitas.
#
# Como repartimos las visitas por nodo rotando las IPs en el
# nameserver y los resolvedores de DNS eligen un nameserver
# aleatoriamente, la cantidad de visitas se reparte
# equitativamente.
#
# XXX: Remover cuando podamos centralizar los AccessLog
#
# @return [Integer]
def nodes
@nodes ||= ENV.fetch('NODES', 1).to_i
end
def period def period
@period ||= begin @period ||= begin
p = params.permit(:period_start, :period_end) p = params.permit(:period_start, :period_end)

View file

@ -2,6 +2,19 @@
# Helpers # Helpers
module ApplicationHelper module ApplicationHelper
BRACKETS = /[\[\]]/.freeze
ALPHA_LARGE = [*'a'..'z', *'A'..'Z'].freeze
# Devuelve un indentificador aleatorio que puede usarse como atributo
# HTML. Reemplaza Nanoid. El primer caracter siempre es alfabético.
#
# @return [String]
def random_id
SecureRandom.urlsafe_base64.tap do |s|
s[0] = ALPHA_LARGE.sample
end
end
# Devuelve el atributo name de un campo anidado en el formato que # Devuelve el atributo name de un campo anidado en el formato que
# esperan los helpers *_field # esperan los helpers *_field
# #
@ -19,6 +32,14 @@ module ApplicationHelper
[root, name] [root, name]
end end
# Obtiene un ID
#
# @param base [String]
# @param attribute [String, Symbol]
def id_for(base, attribute)
"#{base.gsub(BRACKETS, '_')}_#{attribute}".squeeze('_')
end
def plain_field_name_for(*names) def plain_field_name_for(*names)
root, name = field_name_for(*names) root, name = field_name_for(*names)
@ -134,9 +155,17 @@ module ApplicationHelper
private private
# Obtiene la traducción desde el esquema en el idioma actual, o por
# defecto en el idioma del sitio. De lo contrario trae una traducción
# genérica.
#
# Si el idioma por defecto tiene un String vacía, se asume que no
# texto.
#
# @return [String,nil]
def post_t(*attribute, post:, type:) def post_t(*attribute, post:, type:)
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) || post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence ||
post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) || post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) ||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}") I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence
end end
end end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Métodos reutilizables para trabajar con StrongParams
module StrongParamsHelper
# Obtiene el valor de un param
#
# @todo No hay una forma mejor de hacer esto?
# @param param [Symbol]
# @param :optional [Bool]
# @return [nil,String]
def pluck_param(param, optional: false)
if optional
params.permit(param).values.first.presence
else
params.require(param).presence
end
end
end

View file

@ -0,0 +1,138 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["item", "search", "current", "placeholder"];
connect() {
// TODO: Stimulus >1
this.newArrayValueURL = new URL(window.location.origin);
const [ pathname, search ] = this.element.dataset.arrayNewArrayValue.split("?");
this.newArrayValueURL.pathname = pathname;
this.newArrayValueURL.search = `?${search}`;
this.originalValue = JSON.parse(this.element.dataset.arrayOriginalValue);
}
/*
* Al eliminar el ítem, buscamos por su ID y lo eliminamos del
* documento.
*/
remove(event) {
// TODO: Stimulus >1
event.preventDefault();
this.itemTargets
.find((x) => x.id === event.target.dataset.removeTargetParam)
?.remove();
}
/*
* Al buscar, eliminamos las tildes y mayúsculas para no depender de
* cómo se escribió.
*
* Luego buscamos eso en el valor limpio, ignorando los items que ya
* están activados.
*
* Si el término de búsqueda está vacío, volvemos a la lista original.
*/
search(event) {
const needle = this.searchTarget.value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
if (needle) {
for (const itemTarget of this.itemTargets) {
itemTarget.style.display =
itemTarget.querySelector("input")?.checked ||
itemTarget.dataset.searchableValue.includes(needle)
? ""
: "none";
}
} else {
for (const itemTarget of this.itemTargets) {
itemTarget.style.display = "";
}
}
}
/*
* Obtiene el input de un elemento
*
* @param [HTMLElement]
* @return [HTMLElement,nil]
*/
inputFrom(target) {
if (target.tagName === "INPUT") return target;
return target.querySelector("input");
}
/*
* Detecta si el item es o contiene un checkbox/radio activado.
*
* @param [HTMLElement]
* @return [Bool]
*/
isChecked(itemTarget) {
return this.inputFrom(itemTarget)?.checked || false;
}
cancelWithEscape(event) {
if (event?.key !== "Escape") return;
this.cancel();
}
/*
* Al cancelar, se vuelve al estado original de la lista
*/
cancel(event = undefined) {
for (const itemTarget of this.itemTargets) {
const input = this.inputFrom(itemTarget);
input.checked = this.originalValue.includes(itemTarget.dataset.value);
}
}
/*
* Al aceptar, se envía todo el listado de valores nuevos al _backend_
* para que devuelva la representación de cada ítem en HTML. Además,
* se guarda el nuevo valor como la lista original, para la próxima
* cancelación.
*/
accept(event) {
this.currentTarget.innerHTML = "";
this.originalValue = [];
const signal = window.abortController?.signal;
for (const itemTarget of this.itemTargets) {
if (!itemTarget.dataset.value) continue;
if (!this.isChecked(itemTarget)) continue;
this.originalValue.push(itemTarget.dataset.value);
this.newArrayValueURL.searchParams.set("value", itemTarget.dataset?.sendValue || itemTarget.dataset?.value);
const placeholder = this.placeholderTarget.content.firstElementChild.cloneNode(true);
this.currentTarget.appendChild(placeholder);
fetch(this.newArrayValueURL, { signal })
.then((response) => response.text())
.then((body) => {
const template = document.createElement("template");
template.innerHTML = body;
placeholder.replaceWith(template.content.firstElementChild);
});
}
// TODO: Stimulus >1
this.element.dataset.arrayOriginalValue = JSON.stringify(
this.originalValue,
);
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
export default class extends Controller { export default class extends Controller {
static targets = []; static targets = [];

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
// https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button // https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button
export default class extends Controller { export default class extends Controller {

View file

@ -0,0 +1,10 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
/*
* Previene el envío de un formulario al presionar enter
*/
prevent(event) {
if (event.key == "Enter") event.preventDefault();
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
import bsCustomFileInput from "bs-custom-file-input"; import bsCustomFileInput from "bs-custom-file-input";
document.addEventListener("turbolinks:load", () => { document.addEventListener("turbolinks:load", () => {

View file

@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["invalid", "submitting"];
// @todo Stimulus >1
get submittingIdValue() {
return this.element.dataset?.formValidationSubmittingIdValue;
}
// @todo Stimulus >1
get invalidIdValue() {
return this.element.dataset?.formValidationInvalidIdValue;
}
connect() {
this.element.setAttribute("novalidate", true);
for (const input of this.element.elements) {
if (input.type === "button" || input.type === "submit") continue;
if (input.dataset.action) {
input.dataset.action = `${input.dataset.action} htmx:validation:validate->form-validation#submit`;
} else {
input.dataset.action = "htmx:validation:validate->form-validation#submit";
}
}
}
submit(event = undefined) {
if (this.submitting) return;
this.submitting = true;
event?.preventDefault();
if (this.element.reportValidity()) {
this.element.classList.remove("was-validated");
if (!this.element.getAttributeNames().some(x => x.startsWith("hx-"))) this.element.submit();
window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.submittingIdValue } }));
} else {
event?.stopPropagation();
this.element.classList.add("was-validated");
window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.invalidIdValue } }));
}
this.submitting = false;
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css") require("leaflet/dist/leaflet.css")
import L from 'leaflet' import L from 'leaflet'

View file

@ -0,0 +1,107 @@
import { Controller } from "@hotwired/stimulus";
/*
* Un controlador que imita a HTMX
*/
export default class extends Controller {
connect() {
// @todo Convertir en <template>
this.placeholder = "<span class=\"placeholder w-100\" aria-hidden=\"true\"></span>";
}
/*
* Obtiene la URL y elimina la acción.
*
* @param event [Event]
*/
getUrlOnce(event) {
this.getUrl(event);
event.target.dataset.action = event.target.dataset.action.replace("htmx#getUrlOnce", "").trim();
}
/*
* Lanza el evento que va a descargar la URL y agregarse en algún
* lado.
*
* @param event [Event]
*/
getUrl(event) {
// @todo Stimulus >1
const value = event.target.dataset.htmxGetUrlParam;
if (value) {
window.dispatchEvent(new CustomEvent("htmx:getUrl", { detail: { value } }));
} else {
console.error("Missing data-htmx-get-url-param attribute on element", event.target);
}
}
/*
* Realiza una petición.
*
* @param url [String]
* @return [Promise<Response>]
*/
async request(url) {
const headers = new Headers();
const signal = window.abortController?.signal;
headers.set("HX-Request", "true");
return fetch(url, { headers, signal });
}
/*
* Obtiene la URL enviada por el evento y reemplaza el contenido del
* elemento.
*/
async swap(event) {
const response = await this.request(event.detail.value);
if (response.ok) {
this.element.innerHTML = this.placeholder;
this.element.innerHTML = await response.text();
this.triggerEvents(response.headers);
window.htmx.process(this.element);
} else {
console.error(response);
}
}
/*
* Agrega el resultado de la descarga al final del elemento.
*/
async beforeend(event) {
const response = await this.request(event.detail.value);
if (response.ok) {
this.element.insertAdjacentHTML("beforeend", this.placeholder);
this.element.lastElementChild.outerHTML = await response.text();
this.triggerEvents(response.headers);
// @todo Asume que cada endpoint solo devuelve un elemento por vez
window.htmx.process(this.element.lastElementChild);
} else {
console.error(response);
}
}
/*
* Lanza los eventos que vienen con la respuesta.
*/
triggerEvents(headers) {
if (!headers.has("HX-Trigger")) return;
const events = JSON.parse(headers.get("HX-Trigger"));
setTimeout(() => {
for (const event in events) {
const detail = events[event];
window.dispatchEvent(new CustomEvent(event, { detail }));
}
}, 1);
}
}

View file

@ -1,8 +1,8 @@
// Load all the controllers within this directory and all subdirectories. // Load all the controllers within this directory and all subdirectories.
// Controller files must be named *_controller.js. // Controller files must be named *_controller.js.
import { Application } from "stimulus" import { Application } from "@hotwired/stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers" import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
const application = Application.start() const application = Application.start()
const context = require.context("controllers", true, /_controller\.js$/) const context = require.context("controllers", true, /_controller\.js$/)

View file

@ -0,0 +1,95 @@
import { Controller } from "@hotwired/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);
}
/*
* Abrir otro modal, enviando el ID a toda la ventana.
*/
showAnother(event = undefined) {
event?.preventDefault();
if (!event.target?.dataset?.modalShowValue) return;
window.dispatchEvent(new CustomEvent("modal:show", { detail: { id: event.target.dataset.modalShowValue, previousFocus: event.target.id } }));
}
/*
* Podemos enviar la orden de apertura como un click o como un
* CustomEvent incluyendo el id del modal como detail.
*
* El elemento clicleable puede tener un valor que se refiera a otro
* modal también.
*/
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";
this.modalTarget.setAttribute("role", "dialog");
this.modalTarget.setAttribute("aria-modal", true);
this.modalTarget.removeAttribute("aria-hidden");
window.document.body.classList.add("modal-open");
if (event?.detail?.previousFocus) {
this.previousFocus = window.document.getElementById(event.detail.previousFocus);
} else {
this.previousFocus = event?.target;
}
setTimeout(() => {
this.modalTarget.classList.add("show");
this.backdropTarget.classList.add("show");
this.modalTarget.focus();
}, 1);
}
hideWithEscape(event) {
if (event?.key !== "Escape") return;
this.hide();
}
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");
this.modalTarget.setAttribute("aria-hidden", true);
this.modalTarget.removeAttribute("role");
this.modalTarget.removeAttribute("aria-modal");
this.previousFocus?.focus();
setTimeout(() => {
this.modalTarget.style.display = "";
this.backdropTarget.style.display = "";
}, 500);
window.document.body.classList.remove("modal-open");
}
}

View file

@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus";
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
export default class extends Controller {
static targets = ["textarea"];
connect() {
this.editor =
new SuttyEditor({
target: this.element,
props: {
textareaEl: this.textareaTarget,
},
});
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css") require("leaflet/dist/leaflet.css")
import L from 'leaflet' import L from 'leaflet'

View file

@ -0,0 +1,43 @@
import { Controller } from "@hotwired/stimulus";
/*
* Solo se puede mostrar una notificación a la vez
*/
export default class extends Controller {
// @todo Stimulus >1
get showClasses() {
return (this.element.dataset?.notificationShowClass || "").split(" ").filter(x => x);
}
// @todo Stimulus >1
get hideClasses() {
return (this.element.dataset?.notificationHideClass || "").split(" ").filter(x => x);
}
/*
* Al recibir el evento de mostrar, si no está dirigido al elemento
* actual, se oculta.
*/
show(event = undefined) {
if (event?.detail?.value !== this.element.id) {
this.hide({ detail: { value: this.element.id } });
return;
}
this.element.classList.remove("d-none");
setTimeout(() => {
this.element.classList.remove(...this.hideClasses);
this.element.classList.add(...this.showClasses);
}, 1);
}
hide(event = undefined) {
if (event?.detail?.value !== this.element.id) return;
this.element.classList.remove(...this.showClasses);
this.element.classList.add(...this.hideClasses);
setTimeout(() => this.element.classList.add("d-none"), 150);
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
/* /*
* Permite reordenar las filas de una tabla. * Permite reordenar las filas de una tabla.

View file

@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus";
/*
* Para poder indicar que al menos uno del grupo de checkboxes es
* obligatorio, marcamos uno como `required` (que es el que mostraría el
* error) y se lo quitamos cuando detectamos que alguno cambió.
*/
export default class extends Controller {
static targets = ["required", "checkbox"];
connect() {
}
checkboxTargetConnected(checkboxTarget) {
if (checkboxTarget.checked) {
this.requiredTarget.required = false;
this.revalid();
}
}
/*
* El grupo deja de ser obligatorio cuando al menos uno está activo.
*/
change(event = undefined) {
if (event.target.checked) {
this.requiredTarget.required = false;
} else {
this.requiredTarget.required = !Array.from(this.checkboxTargets).some(x => x.checked);
}
for (const checkbox of this.checkboxTargets) {
if (checkbox === event.target) continue;
checkbox.required = !event.target.checked;
}
}
/*
* Si el checkbox es considerado inválido, transmitir todos los
* estados a los checkboxes.
*/
invalid(event = undefined) {
for (const checkbox of this.checkboxTargets) {
checkbox.required = true;
}
}
revalid(event = undefined) {
for (const checkbox of this.checkboxTargets) {
checkbox.required = false;
}
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
export default class extends Controller { export default class extends Controller {
static targets = ["toggle", "input"]; static targets = ["toggle", "input"];

View file

@ -0,0 +1,55 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.originalFormDataSerialized = this.serializeFormData(this.element);
this.submitting = false;
}
submit(event) {
this.submitting = true;
}
unsaved(event) {
if (this.submitting) return;
if (!this.hasChanged()) return;
this.submitting = false;
event.preventDefault();
event.returnValue = true;
}
unsavedTurbolinks(event) {
if (this.submitting) return;
if (!this.hasChanged()) return;
this.submitting = false;
if (window.confirm(this.element.dataset.unsavedChangesConfirmValue)) return;
event.preventDefault();
}
formData(form) {
const formData = new FormData(form);
formData.delete("authenticity_token");
return formData;
}
/*
* Elimina saltos de línea y espacios al serializar, para evitar
* detectar cambios cuando cambió el espaciado, por ejemplo cuando el
* editor con formato aplica espacios o elimina saltos de línea.
*/
serializeFormData(form) {
return (new URLSearchParams(this.formData(form))).toString().replaceAll("+", "").replaceAll("%0A", "");
}
hasChanged() {
return (this.originalFormDataSerialized !== this.serializeFormData(this.element));
}
}

View file

@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
window.htmx.trigger(hx, "htmx:abort"); window.htmx.trigger(hx, "htmx:abort");
} }
}); });
document.addEventListener("htmx:resetForm", (event) => {
event.target.reset();
});

View file

@ -4,6 +4,4 @@ import './input-tag'
import './prosemirror' import './prosemirror'
import './timezone' import './timezone'
import './turbolinks-anchors' import './turbolinks-anchors'
import './validation'
import './new_editor'
import './htmx_abort' import './htmx_abort'

View file

@ -1,14 +0,0 @@
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
document.addEventListener("turbolinks:load", () => {
document.querySelectorAll(".new-editor").forEach((editorContainer) => {
new SuttyEditor({
target: editorContainer,
props: {
textareaEl: editorContainer.querySelector("textarea"),
},
});
});
});

View file

@ -1,34 +0,0 @@
document.addEventListener('turbolinks:load', () => {
// Al enviar el formulario del artículo, aplicar la validación
// localmente y actualizar los comentarios para lectores de pantalla.
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', event => {
const invalid_help = form.querySelectorAll('.invalid-help')
const sending_help = form.querySelectorAll('.sending-help')
invalid_help.forEach(i => i.classList.add('d-none'))
sending_help.forEach(i => i.classList.add('d-none'))
form.querySelectorAll('[aria-invalid="true"]').forEach(aria => {
aria.setAttribute('aria-invalid', false)
aria.setAttribute('aria-describedby', aria.parentElement.querySelector('.feedback').id)
})
if (form.checkValidity() === false) {
event.preventDefault()
event.stopPropagation()
invalid_help.forEach(i => i.classList.remove('d-none'))
form.querySelectorAll(':invalid').forEach(invalid => {
invalid.setAttribute('aria-invalid', true)
invalid.setAttribute('aria-describedby', invalid.parentElement.querySelector('.invalid-feedback').id)
})
} else {
sending_help.forEach(i => i.classList.remove('d-none'))
}
form.classList.add('was-validated')
})
})
})

View file

@ -41,4 +41,5 @@ Rails.start()
Turbolinks.start() Turbolinks.start()
ActiveStorage.start() ActiveStorage.start()
window.htmx = require('htmx.org/dist/htmx.js') window.htmx = require("@suttyweb/htmx.org/dist/htmx.cjs.js");
window.htmx.config.selfRequestsOnly = true;

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
module CoreExtensions
module String
# Elimina tildes
module RemoveDiacritics
def remove_diacritics
unicode_normalize(:nfd).gsub(/[^\x00-\x7F]/, '')
end
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Metadata
# Hasta ahora veníamos habilitando la opción de romper
# retroactivamente relaciones, sin informar que estaba sucediendo.
# Con este módulo, todas las relaciones que ya tienen una relación
# inversa son ignoradas.
module UnusedValuesConcern
extend ActiveSupport::Concern
included do
# Excluye el Post actual y todos los que ya tengan una relación
# inversa, para no romperla.
#
# @return [Array]
def values
@values ||= posts.map do |p|
next if p.uuid.value == post.uuid.value
disabled = false
# El campo está deshabilitado si está completo y no incluye el
# post actual.
if inverse?
disabled = p[inverse].present? && ![p[inverse].value].flatten.include?(post.uuid.value)
end
[title(p), p.uuid.value, disabled]
end.compact
end
end
end
end

View file

@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
true && !private? true && !private?
end end
def titleize?
true
end
def to_s def to_s
value.join(', ') value.select(&:present?).join(', ')
end end
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo # Obtiene el valor desde el documento, convirtiéndolo a Array si no lo

View file

@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
'' ''
end end
def to_s
belongs_to.try(:title).try(:value).to_s
end
# Obtiene el valor desde el documento. # Obtiene el valor desde el documento.
# #
# @return [String] # @return [String]
@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
document.data[name.to_s] document.data[name.to_s]
end end
def validate
super
errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists?
errors.empty?
end
# Guardar y guardar la relación inversa también, eliminando la # Guardar y guardar la relación inversa también, eliminando la
# relación anterior si existía. # relación anterior si existía.
def save def save
@ -97,6 +93,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
end end
def sanitize(uuid) def sanitize(uuid)
uuid.to_s.gsub(/[^a-f0-9\-]/i, '') uuid.to_s.gsub(/[^a-f0-9-]/i, '')
end end
end end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'htmlbeautifier'
# Se encarga del contenido del artículo y quizás otros campos que # Se encarga del contenido del artículo y quizás otros campos que
# requieran texto largo. # requieran texto largo.
class MetadataContent < MetadataTemplate class MetadataContent < MetadataTemplate
@ -86,7 +88,7 @@ class MetadataContent < MetadataTemplate
end end
end end
html.to_s.html_safe HtmlBeautifier.beautify(html.to_s).html_safe
end end
# Limpia estilos en base a una lista de permitidos # Limpia estilos en base a una lista de permitidos

View file

@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
# XXX: Esto ayuda a deserializar en {Site#everything_of} # XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end def values; end
# Usar la descripción
def titleize?
true
end
# Devolver la descripción
#
# @return [String]
def to_s
value['description'].to_s
end
def validate def validate
super super

View file

@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
return true unless changed? return true unless changed?
return true if empty? return true if empty?
self[:value] = value.transform_values(&:to_f) self[:value] = value.transform_values(&:to_f).to_h
self[:value] = encrypt(value) if private? self[:value] = encrypt(value) if private?
true true

View file

@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
def save def save
super super
self[:value] = self[:value].uniq
return true unless changed? return true unless changed?
return true unless inverse? return true unless inverse?
(had_many - has_many).each do |remove| (had_many - has_many).each do |remove|
# No modificar nada si la relación ya estaba deshecha
next unless remove[inverse]&.value == post.uuid.value
remove[inverse]&.value = remove[inverse].default_value remove[inverse]&.value = remove[inverse].default_value
end end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Implementa la nueva interfaz de gestión de valores
class MetadataNewArray < MetadataArray
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Nueva interfaz
class MetadataNewBelongsTo < MetadataBelongsTo
include Metadata::UnusedValuesConcern
end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para relaciones muchos a muchos
class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Interfaz nueva para uno a muchos
class MetadataNewHasMany < MetadataHasMany
include Metadata::UnusedValuesConcern
end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para relaciones 1:1
class MetadataNewHasOne < MetadataHasOne; end

View file

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

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Nueva interfaz
class MetadataNewPredefinedValue < MetadataPredefinedValue
def values
@values ||= (required ? {} : { I18n.t('posts.attributes.new_predefined_value.empty') => '' }).merge(super)
end
end

View file

@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
[v[I18n.locale.to_s], k] [v[I18n.locale.to_s], k]
end&.to_h end&.to_h
end end
# Devolver los valores legibles por humanes
#
# @todo Debería devolver los valores en el idioma del post, no de le
# usuarie
# @return [String]
def to_s
values.invert.select { |x, k| value.include?(x) }.values.join(', ')
end
end end

View file

@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {} @values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
end end
def to_s
values.invert[value].to_s
end
private private
# Solo permite almacenar los valores predefinidos. # Solo permite almacenar los valores predefinidos.

View file

@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
false false
end end
def titleize?
false
end
def indexable_values def indexable_values
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
@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
end end
def title(post) def title(post)
"#{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
# Encuentra el filtro
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end 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, '')
end) end)
end end
end end

View file

@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar # Trae el slug desde el título si existe o una string al azar
def default_value def default_value
title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid Jekyll::Utils.slugify(title || SecureRandom.uuid, mode: site.slugify_mode)
end end
def value def value

View file

@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
true && !private? true && !private?
end end
def titleize?
true
end
private private
# No se permite HTML en las strings # No se permite HTML en las strings

View file

@ -16,6 +16,11 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
false false
end end
# El valor puede ser parte de un título auto-generado
def titleize?
false
end
def inspect def inspect
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>" "#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
end end

View file

@ -1,4 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# Un campo de texto largo # Un campo de texto largo
class MetadataText < MetadataString; end class MetadataText < MetadataString
def titleize?
false
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
# El título es obligatorio para todos los Post, si el esquema no lo
# incluye, tenemos que poder generar un valor legible por humanes.
class MetadataTitle < MetadataString
def titleize?
false
end
# Siempre recalcular el título
def value
self[:value] = default_value
end
# Obtener todos los valores de texto del artículo y generar un título
# en base a eso.
#
# @return [String]
def default_value
post.attributes.select do |attr|
post[attr].titleize?
end.map do |attr|
post[attr].to_s
end.compact.join(' ').strip.squeeze(' ')
end
end

View file

@ -12,9 +12,21 @@ class Post
DEFAULT_ATTRIBUTES = %i[site document layout].freeze DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos # Otros atributos que no vienen en los metadatos
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze PUBLIC_ATTRIBUTES = %i[title lang date uuid created_at].freeze
ALIASED_ATTRIBUTES = %i[locale].freeze
ATTR_SUFFIXES = %w[? =].freeze ATTR_SUFFIXES = %w[? =].freeze
ATTRIBUTE_DEFINITIONS = {
'title' => { 'type' => 'title', 'required' => true },
'lang' => { 'type' => 'lang', 'required' => true },
'date' => { 'type' => 'document_date', 'required' => true },
'uuid' => { 'type' => 'uuid', 'required' => true },
'created_at' => { 'type' => 'created_at', 'required' => true },
'slug' => { 'type' => 'slug', 'required' => true },
'path' => { 'type' => 'path', 'required' => true },
'locale' => { 'alias' => 'lang' }
}.freeze
class PostError < StandardError; end class PostError < StandardError; end
class UnknownAttributeError < PostError; end class UnknownAttributeError < PostError; end
@ -49,10 +61,12 @@ class Post
@layout = args[:layout] @layout = args[:layout]
@site = args[:site] @site = args[:site]
@document = args[:document] @document = args[:document]
@attributes = layout.attributes + PUBLIC_ATTRIBUTES @attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
@errors = {} @errors = {}
@metadata = {} @metadata = {}
layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
# Leer el documento si existe # Leer el documento si existe
# @todo Asignar todos los valores a self[:value] luego de leer # @todo Asignar todos los valores a self[:value] luego de leer
document&.read! unless new? document&.read! unless new?
@ -127,6 +141,7 @@ class Post
src = element.attributes['src'] src = element.attributes['src']
next unless src&.value&.start_with? 'public/' next unless src&.value&.start_with? 'public/'
file = MetadataFile.new(site: site, post: self, document: document, layout: layout) file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
file.value['path'] = src.value file.value['path'] = src.value
@ -188,13 +203,13 @@ class Post
def method_missing(name, *_args) def method_missing(name, *_args)
# Limpiar el nombre del atributo, para que todos los ayudantes # Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio # reciban el método en limpio
unless attribute? name raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
raise NoMethodError, I18n.t('exceptions.post.no_method', method: name)
end
define_singleton_method(name) do define_singleton_method(name) do
template = layout.metadata[name.to_s] template = layout.metadata[name.to_s]
return public_send(template['alias'].to_sym) if template.key?('alias')
@metadata[name] ||= @metadata[name] ||=
MetadataFactory.build(document: document, MetadataFactory.build(document: document,
post: self, post: self,
@ -210,55 +225,6 @@ class Post
public_send name public_send name
end end
# TODO: Mover a method_missing
def slug
@metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
post: self, required: true)
end
# TODO: Mover a method_missing
def date
@metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
type: :document_date, post: self, required: true)
end
# TODO: Mover a method_missing
def path
@metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
post: self, required: true)
end
# TODO: Mover a method_missing
def lang
@metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
post: self, required: true)
end
alias locale lang
# TODO: Mover a method_missing
def uuid
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
post: self, required: true)
end
# La fecha de creación inmodificable del post
def created_at
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
included = DEFAULT_ATTRIBUTES.include?(mid) ||
PRIVATE_ATTRIBUTES.include?(mid) ||
PUBLIC_ATTRIBUTES.include?(mid)
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
end
# Devuelve los strong params para el layout. # Devuelve los strong params para el layout.
# #
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende # XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
@ -430,6 +396,19 @@ class Post
@nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name) @nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name)
end end
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
included = DEFAULT_ATTRIBUTES.include?(mid) ||
PRIVATE_ATTRIBUTES.include?(mid) ||
PUBLIC_ATTRIBUTES.include?(mid) ||
ALIASED_ATTRIBUTES.include?(mid)
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
end
private private
# Levanta un error si al construir el artículo no pasamos un atributo. # Levanta un error si al construir el artículo no pasamos un atributo.

View file

@ -3,26 +3,43 @@
# Este servicio se encarga de crear artículos y guardarlos en git, # Este servicio se encarga de crear artículos y guardarlos en git,
# asignándoselos a une usuarie # asignándoselos a une usuarie
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Si estamos pasando el UUID con los parámetros, el post quizás
# existe.
#
# @return [Post]
def create_or_update
uuid = params.require(base).permit(:uuid).values.first
if uuid.blank?
create
elsif (indexed_post = site.indexed_posts.find_by(post_id: uuid)).present?
self.post = indexed_post.post
update
else
create
end
end
# Crea un artículo nuevo # Crea un artículo nuevo
# #
# @return Post # @return Post
def create def create
self.post = site.posts(lang: locale) self.post ||= site.posts(lang: locale).build(layout: layout)
.build(layout: layout)
post.usuaries << usuarie post.usuaries << usuarie
post.draft.value = true if site.invitade? usuarie post.draft.value = true if post.attribute?(:draft) && 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
# Crea los posts anidados # Crea los posts anidados
create_nested_posts! post, params[:post] create_nested_posts! post, params[base]
post.save post.save
update_related_posts update_related_posts
commit(action: :created, add: files) commit(action: :created, add: files) if post.valid?
update_site_license! update_site_license!
@ -46,14 +63,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
def update def update
post.usuaries << usuarie post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie params[base][:draft] = true if site.invitade? usuarie
# Eliminar ("mover") el archivo si cambió de ubicación. # Eliminar ("mover") el archivo si cambió de ubicación.
if post.update(post_params) if post.update(post_params)
rm = [] rm = []
rm << post.path.value_was if post.path.changed? rm << post.path.value_was if post.path.changed?
create_nested_posts! post, params[:post] create_nested_posts! post, params[base]
update_related_posts update_related_posts
# Es importante que el artículo se guarde primero y luego los # Es importante que el artículo se guarde primero y luego los
@ -82,7 +99,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|
@ -105,6 +122,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]
@ -126,7 +150,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
@ -137,11 +161,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end end
def locale def locale
params.dig(:post, :lang)&.to_sym || I18n.locale params.dig(base, :lang)&.to_sym || I18n.locale
end end
def layout def layout
params.dig(:post, :layout) || params[:layout] params.dig(base, :layout) || params[:layout]
end end
# Actualiza los artículos relacionados según los métodos que los # Actualiza los artículos relacionados según los métodos que los
@ -173,15 +197,16 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Si les usuaries modifican o crean una licencia, considerarla # Si les usuaries modifican o crean una licencia, considerarla
# personalizada en el panel. # personalizada en el panel.
def update_site_license! def update_site_license!
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom? return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom') site.update licencia: Licencia.find_by_icons('custom')
end end
end
# Encuentra todos los posts anidados y los crea o modifica # Encuentra todos los posts anidados y los crea o modifica
def create_nested_posts!(post, params) def create_nested_posts!(post, params)
post.nested_attributes.each do |nested_attribute| post.nested_attributes.each do |nested_attribute|
nested_metadata = post[nested_attribute] nested_metadata = post[nested_attribute]
next unless params[nested_metadata].present?
# @todo find_or_initialize # @todo find_or_initialize
nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested) 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 nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash

View file

@ -1,2 +1,2 @@
.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] } .alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', **local_assigns }
= yield = yield

View file

@ -0,0 +1,13 @@
-#
Un botón
@param :content [String] Contenido
@param :action [String] Acción de Stimulus
@param :target [String] Objetivo de Stimulus
@param [Hash] Atributos en bruto, con mayor prioridad que action y target
:ruby
attributes = local_assigns.to_h.except(:content)
attributes[:data] ||= {}
attributes[:data][:action] ||= local_assigns[:action]
attributes[:data][:target] ||= local_assigns[:target]
%button.btn.btn-secondary{ type: 'button', **attributes }= content

View file

@ -0,0 +1,8 @@
.card{ **local_assigns.except(:image, :description) }
- if local_assigns[:image]
= image_tag url_for(local_assigns[:image]), alt: local_assigns[:description], class: 'img-fluid'
.card-body
.card-title= title
= yield

View file

@ -1,6 +1,10 @@
- help_id = "#{id}_help" :ruby
help_id = "#{id}_help"
checkbox_attributes = local_assigns.slice(:id, :type, :name, :value, :required, :checked, :data, :disabled)
checkbox_attributes[:type] ||= 'checkbox'
.custom-control.custom-checkbox .custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required } %input.custom-control-input{ **checkbox_attributes }
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content %label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
%small.form-text.text-muted{ id: help_id }= yield - if (block = yield).present?
%small.form-text.text-muted{ id: help_id }= block

View file

@ -0,0 +1,44 @@
-#
# Modal
@see {https://getbootstrap.com/docs/4.6/components/modal/}
@see {https://github.com/bullet-train-co/nice_partials/issues/99}
@param :id [String] El ID del modal
@param :modal_content_attributes [Hash] Atributos para el contenido del modal
@param :hide_actions [Array<String>] Acciones al ocultar el modal
@yield :ID_body Contenido
@yield :ID_header Contenido del header (opcional)
@yield :ID_footer Contenido del pie (opcional)
@example
= render 'bootstrap/modal', id: 'algo' do |partial|
- content_for :algo_header do
= 'título'
- content_for :algo_body do
= 'contenido'
- content_for :algo_footer do
= 'pie'
:ruby
local_assigns[:hide_actions] ||= []
local_assigns[:hide_actions] << 'click->modal#hide'
local_assigns[:keydown_actions] ||= []
local_assigns[:keydown_actions] << 'keydown->modal#hideWithEscape'
local_assigns[:modal_content_attributes] ||= {}
-# XXX: Necesario para poder generar todas las demás
= yield
.modal.fade{ tabindex: -1, aria: { hidden: 'true' }, data: { 'modal-target': 'modal', action: local_assigns[:keydown_actions].join(' ') } }
.modal-backdrop.fade{ data: { 'modal-target': 'backdrop', action: local_assigns[:hide_actions].join(' ') } }
.modal-dialog.modal-dialog-scrollable.modal-dialog-centered.modal-lg
.modal-content{ **local_assigns[:modal_content_attributes] }
- if (header = yield(:"#{id}_header")).present?
.modal-header= header
.modal-body= yield(:"#{id}_body")
.modal-footer.flex-nowrap
- if (footer = yield(:"#{id}_footer"))
= footer
- else
%button.btn.btn-secondary.m-0{ type: 'button', data: { action: 'modal#hide' } }= t('.close')

View file

@ -0,0 +1,4 @@
- local_assigns[:ratio] ||= '1by1'
.embed-responsive{ class: "embed-responsive-#{local_assigns[:ratio]}" }
.embed-responsive-item= yield

View file

@ -18,7 +18,7 @@
toggle: 'true', toggle: 'true',
display: 'static', display: 'static',
action: 'dropdown#toggle', action: 'dropdown#toggle',
target: 'dropdown.button' 'dropdown-target': 'button'
}, },
aria: { aria: {
expanded: 'false' expanded: 'false'
@ -28,7 +28,7 @@
.dropdown-menu{ .dropdown-menu{
class: dropdown_classes, class: dropdown_classes,
data: { data: {
target: 'dropdown.dropdown' 'dropdown-target': 'dropdown'
} }
} }
= yield = yield

View file

@ -3,4 +3,4 @@
@param value [String] @param value [String]
@param text [String] @param text [String]
- local_assigns.delete(:text) - local_assigns.delete(:text)
%button.dropdown-item{type: 'submit', data: { target: 'dropdown.item' }, name: name, value: value, **local_assigns.compact }= text %button.dropdown-item{type: 'submit', data: { 'dropdown-target': 'item' }, name: name, value: value, **local_assigns.compact }= text

View file

@ -2,4 +2,4 @@
@param :text [String] Contenido del link @param :text [String] Contenido del link
@param :path [String,Hash] Link @param :path [String,Hash] Link
- local_assigns[:class] = "dropdown-item #{local_assigns[:class]}" - local_assigns[:class] = "dropdown-item #{local_assigns[:class]}"
= link_to text, path, class: local_assigns[:class], data: { target: 'dropdown.item' } = link_to text, path, class: local_assigns[:class], data: { 'dropdown-target': 'item' }

View file

@ -1,4 +1,4 @@
-# -#
@param id [String] @param id [String]
= render 'components/checkbox', id: id, data: { action: 'select-all#toggle', target: 'select-all.toggle', **local_assigns.compact } do = render 'components/checkbox', id: id, data: { action: 'select-all#toggle', 'select-all-target': 'toggle', **local_assigns.compact } do
%span.sr-only= t('.label') %span.sr-only= t('.label')

View file

@ -1,6 +1,6 @@
%hr/ %hr/
- locale = params.permit(:locale) - locale = { locale: (pluck_param(:locale, optional: true) || I18n.locale) }
- if controller_name != 'sessions' - if controller_name != 'sessions'
= link_to t('.sign_in'), new_session_path(resource_name, params: locale), = link_to t('.sign_in'), new_session_path(resource_name, params: locale),

View file

@ -3,7 +3,7 @@
.row.no-gutters.pt-2 .row.no-gutters.pt-2
.col-1 .col-1
= render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { target: 'select-all.input' } = render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { 'select-all-target': 'input' }
.col-11 .col-11
- cache [actor_moderation, profile] do - cache [actor_moderation, profile] do
%h4 %h4

View file

@ -22,7 +22,7 @@
.row.no-gutters .row.no-gutters
.col-1 .col-1
= render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { target: 'select-all.input' }, form: form = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { 'select-all-target': 'input' }, form: form
.col-11 .col-11
- cache [activity_pub, comment] do - cache [activity_pub, comment] do
.d-flex.flex-row.align-items-center.justify-content-between .d-flex.flex-row.align-items-center.justify-content-between

View file

@ -4,7 +4,7 @@
.row.no-gutters.pt-2 .row.no-gutters.pt-2
.col-1 .col-1
= render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { target: 'select-all.input' } = render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { 'select-all-target': 'input' }
.col-11 .col-11
- cache [instance_moderation, instance] do - cache [instance_moderation, instance] do
%h4 %h4

View file

@ -4,7 +4,8 @@
@param post [Post] @param post [Post]
@param site [Site] @param site [Site]
@param dir [String] @param dir [String]
- post.attributes.each do |attribute| @param except [Array<Symbol>]
- (post.attributes - local_assigns[:except].to_a).each do |attribute|
- metadata = post[attribute] - metadata = post[attribute]
- type = metadata.type - type = metadata.type

View file

@ -9,6 +9,7 @@
- next if attribute == :date - next if attribute == :date
- next if attribute == :draft - next if attribute == :draft
- next if attribute == inverse - next if attribute == inverse
- metadata = post[attribute] - metadata = post[attribute]
- cache [post, metadata, I18n.locale] do - cache [post, metadata, I18n.locale] do

View file

@ -0,0 +1,19 @@
- unless post.errors.empty?
- title = t('.errors.title')
- help = t('.errors.help')
= render 'bootstrap/alert' do
%h4= title
%p= help
%ul
- post.errors.each do |attribute, errors|
- if errors.size > 1
%li
%strong= post_label_t attribute, post: post
%ul
- errors.each do |error|
%li= error
- else
%li
%strong= post_label_t attribute, post: post
= errors.first

View file

@ -1,22 +1,4 @@
- unless post.errors.empty? = render 'errors', post: post
- title = t('.errors.title')
- help = t('.errors.help')
= render 'bootstrap/alert' do
%h4= title
%p= help
%ul
- post.errors.each do |attribute, errors|
- if errors.size > 1
%li
%strong= post_label_t attribute, post: post
%ul
- errors.each do |error|
%li= error
- else
%li
%strong= post_label_t attribute, post: post
= errors.first
-# TODO: habilitar form_for -# TODO: habilitar form_for
:ruby :ruby
@ -31,12 +13,19 @@
end end
- dir = t("locales.#{@locale}.dir") - dir = t("locales.#{@locale}.dir")
- submitting_id = random_id
- invalid_id = random_id
- data = {}
- data[:controller] = 'unsaved-changes form-validation'
- data[:action] = 'unsaved-changes#submit form-validation#submit beforeunload@window->unsaved-changes#unsaved turbolinks:before-visit@window->unsaved-changes#unsavedTurbolinks'
- data[:'unsaved-changes-confirm-value'] = t('.confirm')
- data[:'form-validation-submitting-id-value'] = submitting_id
- data[:'form-validation-invalid-id-value'] = invalid_id
-# Comienza el formulario -# Comienza el formulario
= form_tag url, method: method, class: 'form post ' + extra_class, multipart: true do = form_tag url, method: method, class: "form post #{extra_class}", multipart: true, data: data do
-# Botones de guardado -# Botones de guardado
= render 'posts/submit', site: site, post: post = render 'posts/submit', site: site, post: post, invalid: invalid_id, submitting: submitting_id
= hidden_field_tag 'post[layout]', post.layout.name = hidden_field_tag 'post[layout]', post.layout.name
@ -44,4 +33,15 @@
= render 'posts/attributes', site: site, post: post, dir: dir, base: 'post', locale: @locale = render 'posts/attributes', site: site, post: post, dir: dir, base: 'post', locale: @locale
-# Botones de guardado -# Botones de guardado
= render 'posts/submit', site: site, post: post = render 'posts/submit', site: site, post: post, invalid: invalid_id, submitting: submitting_id
-# Formularios usados por los modales
= yield(:post_form)
-#
Acumulador de formularios dinámicos, se van cargando a medida que se
necesitan en lugar de recursivamente.
Nunca se eliminan los modales una vez que se cargan para poder tener
historial de cambios.
%div{ data: { controller: 'htmx', action: 'htmx:getUrl@window->htmx#beforeend' } }

View file

@ -0,0 +1,81 @@
-#
El formulario del artículo, con HTMX activado.
@param :site [Site]
@param :post [Post]
@param :locale [Symbol, String]
@param :dir [Symbol, String]
@param [ActionController::StrongParameters] params
@option params [String] :inverse La relación inversa (opcional)
@option params [String] :form El ID del formulario actual, si tiene botones externos, tiene que estar compartido
@option params [String] :swap Método de intercambio del resultado (HTMX)
@option params [String] :target Elemento donde se carga el resultado (HTMX)
@option params [String] :hide ID del modal a esconder vía evento
@option params [String] :show ID del modal a mostrar vía evento
@option params [String] :base La base del formulario, que luego se envía como parámetro a PostService
@option params [String] :attribute El tipo de atributo, para saber qué respuesta generar
:ruby
except = %i[date]
if (inverse = pluck_param(:inverse, optional: true))
except << inverse.to_sym
end
options = {
id: pluck_param(:form),
multipart: true,
class: 'form post ',
'hx-swap': pluck_param(:swap),
'hx-target': "##{pluck_param(:target)}",
'hx-validate': true,
data: {
controller: 'unsaved-changes form-validation',
action: 'unsaved-changes#submit form-validation#submit beforeunload@window->unsaved-changes#unsaved turbolinks:before-visit@window->unsaved-changes#unsavedTurbolinks',
'form-validation-submitting-id-value': pluck_param(:submitting, optional: true),
'form-validation-invalid-id-value': pluck_param(:invalid, optional: true),
}
}
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
= render 'errors', post: post
-# Parámetros para HTMX
%input{ type: 'hidden', name: 'modal_id', value: pluck_param(:modal_id, optional: true) }
%input{ type: 'hidden', name: 'hide', value: pluck_param((post.errors.empty? ? :show : :hide), optional: true) || pluck_param(:modal_id, optional: true) }
%input{ type: 'hidden', name: 'show', value: pluck_param((post.errors.empty? ? :hide : :show), optional: true) }
%input{ type: 'hidden', name: 'name', value: pluck_param(:name) }
%input{ type: 'hidden', name: 'base', value: pluck_param(:base) }
%input{ type: 'hidden', name: 'form', value: options[:id] }
%input{ type: 'hidden', name: 'dir', value: dir }
%input{ type: 'hidden', name: 'locale', value: locale }
%input{ type: 'hidden', name: 'attribute', value: pluck_param(:attribute) }
%input{ type: 'hidden', name: 'target', value: pluck_param(:target) }
%input{ type: 'hidden', name: 'swap', value: pluck_param(:swap) }
- if params[:inverse].present?
%input{ type: 'hidden', name: 'inverse', value: pluck_param(:inverse) }
- if params[:saved].present?
%input{ type: 'hidden', name: 'saved', value: pluck_param(:saved) }
= hidden_field_tag "#{base}[layout]", post.layout.name
-# Dibuja cada atributo, excepto algunos
= render 'posts/attributes', site: site, post: post, dir: dir, base: base, locale: locale, except: except
-#
Enviamos valores vacíos o arrastrados desde el formulario anterior
para los atributos ignorados
- except.each do |attr|
- if (value = pluck_param(attr, optional: true)).present?
%input{ type: 'hidden', name: "#{base}[#{attr}]", value: value }
= yield(:post_form)

View file

@ -0,0 +1 @@
%li= value

View file

@ -0,0 +1,2 @@
= render 'posts/new_related_post', post: post, modal_id: modal_id
%input{ type: 'hidden', name: name, value: value }

View file

@ -0,0 +1,32 @@
:ruby
image = nil
description = nil
card_id = random_id
if post.post.attribute?(:image) && (image = post.post.image.static_file)
description = post.post.image.value['description']
end
.col.mb-3.p-1{ id: card_id, data: { controller: 'modal' } }
= render('bootstrap/card', image: image, description: description, title: post.title, class: 'h-100') do
- if post.post.attribute?(:description)
%p.card-text= post.post.description.value
-#
Si pasamos el ID del modal, asumimos que hay uno que ya existe y
lo llamamos. Sino, tenemos que abrir el modal genérico y cargarle
el formulario vía "HTMX".
- if local_assigns[:modal_id].present?
= render 'bootstrap/btn', content: t('.edit'), data: { action: 'modal#showAnother', 'modal-show-value': local_assigns[:modal_id] }, id: random_id
- else
- form_params = {}
- form_params[:layout] = post.layout
- form_params[:uuid] = post.post_id
- form_params[:modal_id] = form_params[:show] = modal_id = random_id
-# Asociar un modal con una tarjeta
- form_params[:result_id] = card_id
- form_params[:inverse] = local_assigns[:inverse]
-# @todo Poder indicar en qué elemento queremos asociar lo descargado
= render 'bootstrap/btn', content: t('.edit'), data: { controller: 'htmx', action: 'modal#showAnother htmx#getUrlOnce', 'modal-show-value': modal_id, 'htmx-get-url-param': site_posts_modal_path(post.site, **form_params) }, id: random_id

View file

@ -0,0 +1,19 @@
-#
Para el controlador required-checkbox necesitamos un checkbox oculto
que es obligatorio según si alguno de los checkboxes reales está
seleccionado o no. Al ser obligatorio, va a tener feedback de
validación. Sin embargo, como está oculto, no podemos mostrar el
mensaje de validación nativo del navegador.
@param :required [Boolean]
@param :name [String,Symbol]
@param :initial [Boolean]
@param :feedback [String]
@param :type [String]
- if required
- local_assigns[:feedback] ||= t('.required')
- local_assigns[:type] ||= 'checkbox'
%input.form-control.d-none{ type: local_assigns[:type], name: name, data: { 'required-checkbox-target': 'required', action: 'invalid->required-checkbox#invalid' }, required: initial }
.invalid-feedback.mt-0= local_assigns[:feedback]

View file

@ -1,8 +1,3 @@
- invalid_help = site.config.fetch('invalid_help', t('.invalid_help')) .d-flex.flex-column.flex-md-row.align-items-start.mb-3
- sending_help = site.config.fetch('sending_help', t('.sending_help')) %div= submit_tag t('.save'), class: 'btn btn-secondary submit-post'
.form-group = render 'posts/validation', site: site, submitting: { id: submitting }, invalid: { id: invalid }
= submit_tag t('.save'), class: 'btn btn-secondary submit-post'
= render 'bootstrap/alert', class: 'invalid-help d-none' do
= invalid_help
= render 'bootstrap/alert', class: 'sending-help d-none' do
= sending_help

View file

@ -0,0 +1,16 @@
- invalid = site.config.fetch('invalid', t('.invalid'))
- submitting = site.config.fetch('submitting', t('.submitting'))
- %i[invalid submitting].each do |key|
- local_assigns[key] ||= {}
- local_assigns[key][:data] ||= {}
- local_assigns[key][:data][:target] ||= "form-validation.#{key}"
- local_assigns[key][:data][:action] ||= 'notification:show@window->notification#show'
- local_assigns[key][:data][:controller] ||= 'notification'
- local_assigns[key][:data][:'notification-hide-class'] ||= 'hide'
- local_assigns[key][:data][:'notification-show-class'] ||= 'show'
.d-flex.flex-column
= render 'bootstrap/alert', class: 'm-0 d-none fade', **local_assigns[:invalid] do
= invalid
= render 'bootstrap/alert', class: 'm-0 d-none fade', **local_assigns[:submitting] do
= submitting

View file

@ -0,0 +1,8 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td
- if metadata.value.respond_to? :each
- metadata.value.each do |v|
%span.badge.badge-primary= v
- else
%span.badge.badge-primary{ lang: locale, dir: dir }= metadata.value

View file

@ -0,0 +1,6 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }
- p = metadata.belongs_to
- if p
= link_to p.title.value, site_post_path(site, p.id)

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,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,6 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }
- p = metadata.has_one
- if p
= 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,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }= metadata.to_s

View file

@ -1,3 +1,3 @@
%tr{ id: attribute } %tr{ id: attribute }
%th= post_label_t(attribute, post: post) %th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }= metadata.value %td{ dir: dir, lang: locale }= metadata.to_s

View file

@ -0,0 +1,3 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale }= metadata.value

View file

@ -1,5 +1,5 @@
.form-check .form-check
= hidden_field_tag "#{base}[#{attribute}]", '0', id: '' = hidden_field_tag "#{base}[#{attribute}]", '0', id: nil
.custom-control.custom-switch .custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}]", '1', metadata.value, = check_box_tag "#{base}[#{attribute}]", '1', metadata.value,
class: "custom-control-input #{invalid(post, attribute)}", class: "custom-control-input #{invalid(post, attribute)}",

View file

@ -4,11 +4,11 @@
- when %r{\Avideo/} - when %r{\Avideo/}
= video_tag url_for(metadata.static_file), = video_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid', controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' } data: { 'file-preview-target': 'preview' }
- when %r{\Aaudio/} - when %r{\Aaudio/}
= audio_tag url_for(metadata.static_file), = audio_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid', controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' } data: { 'file-preview-target': 'preview' }
- when 'application/pdf' - when 'application/pdf'
%iframe{ src: url_for(metadata.static_file) } %iframe{ src: url_for(metadata.static_file) }
- else - else
@ -26,7 +26,8 @@
= file_field(*field_name_for(base, attribute, :path), = file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)), **field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}", class: "custom-file-input #{invalid(post, attribute)}",
data: { target: 'file-preview.input', action: 'file-preview#update' }) lang: locale,
data: { 'file-preview-target': 'input', action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path", = label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label' post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',

View file

@ -10,7 +10,7 @@
= text_field(*field_name_for(base, attribute, :lat), = text_field(*field_name_for(base, attribute, :lat),
value: metadata.value['lat'], value: metadata.value['lat'],
**field_options(attribute, metadata), **field_options(attribute, metadata),
data: { target: 'geo.lat' }) data: { 'geo-target': 'lat' })
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lat], metadata: metadata post: post, attribute: [attribute, :lat], metadata: metadata
.col .col
@ -20,8 +20,8 @@
= text_field(*field_name_for(base, attribute, :lng), = text_field(*field_name_for(base, attribute, :lng),
value: metadata.value['lng'], value: metadata.value['lng'],
**field_options(attribute, metadata), **field_options(attribute, metadata),
data: { target: 'geo.lng' }) data: { 'geo-target': 'lng' })
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lng], metadata: metadata post: post, attribute: [attribute, :lng], metadata: metadata
.col-12.mb-3 .col-12.mb-3
%div{ data: { target: 'geo.map' }, style: 'height: 250px' } %div{ data: { 'geo-target': 'map' }, style: 'height: 250px' }

View file

@ -3,7 +3,7 @@
= image_tag url_for(metadata.static_file), = image_tag url_for(metadata.static_file),
alt: metadata.value['description'], alt: metadata.value['description'],
class: 'img-fluid', class: 'img-fluid',
data: { target: 'file-preview.preview' } data: { 'file-preview-target': 'preview' }
-# Mantener el valor si no enviamos ninguna imagen -# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path'] = hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
@ -16,14 +16,15 @@
= image_tag '', = image_tag '',
alt: metadata.value['description'], alt: metadata.value['description'],
class: 'img-fluid', class: 'img-fluid',
data: { target: 'file-preview.preview' } data: { 'file-preview-target': 'preview' }
.custom-file .custom-file
= file_field(*field_name_for(base, attribute, :path), = file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)), **field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}", class: ['custom-file-input', invalid(post, attribute), ('replace-image' if metadata.static_file)].compact.join(' '),
accept: ActiveStorage.web_image_content_types.join(','), accept: ActiveStorage.web_image_content_types.join(','),
data: { target: 'file-preview.input', action: 'file-preview#update' }) lang: locale,
data: { 'file-preview-target': 'input', action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path", = label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label' post_label_t(attribute, :path, post: post), class: 'custom-file-label'
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',

View file

@ -0,0 +1,71 @@
-#
Genera un listado de checkboxes entre los que se puede elegir para guardar
:ruby
id = id_for(base, attribute)
name = "#{base}[#{attribute}][]"
form_id = random_id
controllers = %w[modal array enter]
controllers << 'required-checkbox' if metadata.required
%div{ data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.mb-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group.mb-0
-#
Si la lista es obligatoria, al menos uno de los ítems tiene que
estar activado. Logramos esto con un checkbox oculto que se marca
como obligatorio al validar el formulario.
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0 h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# 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.
%ul.placeholder-glow{ data: { 'array-target': 'current' } }
- metadata.value.each do |value|
= render 'posts/new_array_value', value: value
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
%small.feedback.form-text.text-muted.mt-0.mb-1= post_help_t(metadata.name, post: post)
%input.form-control{ data: { 'array-target': 'search', action: 'input->array#search keydown->enter#prevent' }, 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 do |value|
= render 'targets/array/item', value: value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: value, checked: metadata.value.include?(value), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
- content_for :"#{id}_footer" do
.input-group.w-auto.flex-grow-1.my-0
%input.form-control{ form: form_id, name: 'value', type: 'text', placeholder: t('.add_new'), required: true }
.input-group-append
= render 'bootstrap/btn', content: t('.add', layout: ''), form: 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'
-# Los formularios para HTMX se colocan por fuera del formulario
principal, porque HTML5 no soporta formularios anidados. Los campos
quedan unidos al formulario por su atributo `id`.
Al enviar el formulario se obtiene una nueva opción con el valor
y se la agrega al final del listado.
- content_for :post_form do
%form{ id: form_id, 'hx-get': site_posts_new_array_path(site), 'hx-target': "##{id}_body", 'hx-swap': 'beforeend' }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'id', value: form_id }

View file

@ -0,0 +1,111 @@
-#
Genera un listado de radios entre los que se puede elegir solo uno 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 = random_id
name = "#{base}[#{attribute}]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?, type: 'radio'
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# 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.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- if !metadata.empty? && (indexed_post = site.indexed_posts.find_by(post_id: metadata.value))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] 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: { 'array-target': '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 do |(value, uuid, disabled)|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, type: 'radio', disabled: disabled do
= t('posts.attributes.new_belongs_to.disabled') if disabled
-#
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 }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%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 }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

View file

@ -3,7 +3,7 @@
= render 'posts/attribute_feedback', = render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata post: post, attribute: attribute, metadata: metadata
.new-editor.content{ id: attribute } .new-editor.content{ id: attribute, data: { controller: 'new-editor' } }
= text_area_tag "#{base}[#{attribute}]", metadata.to_s.html_safe, = text_area_tag "#{base}[#{attribute}]", metadata.to_s.html_safe,
dir: dir, lang: locale, dir: dir, lang: locale, 'data-new-editor-target': 'textarea',
**field_options(attribute, metadata), class: 'd-none' **field_options(attribute, metadata), class: 'd-none'

View file

@ -0,0 +1,113 @@
-#
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 = random_id
name = "#{base}[#{attribute}][]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site, inverse: metadata.inverse) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
= hidden_field_tag name, ''
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# 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.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- metadata.value.each do |uuid|
- if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] 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: { 'array-target': '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|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
-#
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: 'modal_id', value: modal_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 }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%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 }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

View file

@ -0,0 +1,113 @@
-#
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 = id_for(base, attribute)
name = "#{base}[#{attribute}][]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# 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.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- metadata.value.each do |uuid|
- if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] 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: { 'array-target': '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 do |(value, uuid, disabled)|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }, disabled: disabled do
= t('posts.attributes.new_has_many.disabled') if disabled
-#
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 }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
-# @todo Forma genérica de arrastrar valores desde un formulario al siguiente
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%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 }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

Some files were not shown because too many files have changed in this diff Show more