mirror of
https://0xacab.org/sutty/sutty
synced 2025-03-14 20:18:18 +00:00
Merge branch 'issue-15068' into 'issue-15066'
Draft: new array #15068 See merge request sutty/sutty!263
This commit is contained in:
commit
01d8de0e97
129 changed files with 2347 additions and 303 deletions
1
Gemfile
1
Gemfile
|
@ -79,6 +79,7 @@ gem 'webpacker'
|
|||
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
||||
gem 'kaminari'
|
||||
gem 'device_detector'
|
||||
gem 'htmlbeautifier'
|
||||
gem 'dry-schema'
|
||||
gem 'rubanok'
|
||||
|
||||
|
|
|
@ -269,6 +269,7 @@ GEM
|
|||
hiredis (0.6.3-x86_64-linux-musl)
|
||||
hiredis-client (0.14.1-x86_64-linux-musl)
|
||||
redis-client (= 0.14.1)
|
||||
htmlbeautifier (1.4.2)
|
||||
http_parser.rb (0.8.0-x86_64-linux-musl)
|
||||
httparty (0.21.0)
|
||||
mini_mime (>= 1.0.0)
|
||||
|
@ -659,6 +660,7 @@ DEPENDENCIES
|
|||
hamlit-rails
|
||||
hiredis
|
||||
hiredis-client
|
||||
htmlbeautifier
|
||||
httparty
|
||||
icalendar
|
||||
image_processing
|
||||
|
|
|
@ -11,6 +11,21 @@ $colors: (
|
|||
"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
|
||||
$primary: $magenta;
|
||||
$secondary: $black;
|
||||
|
@ -20,6 +35,17 @@ $form-feedback-valid-color: $black;
|
|||
$form-feedback-invalid-color: $magenta;
|
||||
$form-feedback-icon-valid-color: $black;
|
||||
$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: (
|
||||
2-plus: 0.75rem
|
||||
|
@ -32,6 +58,16 @@ $sizes: (
|
|||
@import "bootstrap";
|
||||
@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 {
|
||||
.#{$color} {
|
||||
color: var(--#{$color});
|
||||
|
@ -60,6 +96,8 @@ $sizes: (
|
|||
--foreground: #{$black};
|
||||
--background: #{$white};
|
||||
--color: #{$magenta};
|
||||
--card-border-color: #{rgba($black, .125)};
|
||||
--modal-content-border-color: rgba(#{$black}, .2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
@ -67,6 +105,8 @@ $sizes: (
|
|||
--foreground: #{$white};
|
||||
--background: #{$black};
|
||||
--color: #{$cyan};
|
||||
--card-border-color: #{rgba($white, .125)};
|
||||
--modal-content-border-color: #{rgba($white, .2)};
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
@ -87,13 +127,17 @@ $sizes: (
|
|||
box-shadow: 0 0 0 0.2rem $cyan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Encontrar la forma de generar esto desde los locales de Rails
|
||||
$custom-file-text: (
|
||||
en: 'Browse',
|
||||
es: 'Buscar archivo'
|
||||
);
|
||||
@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-checkbox {
|
||||
.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-family: 'Saira';
|
||||
|
@ -318,10 +362,6 @@ svg {
|
|||
}
|
||||
}
|
||||
|
||||
.custom-control-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.designs {
|
||||
.design {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ $cyan: #13fefe;
|
|||
--foreground: #{$white};
|
||||
--background: #{$black};
|
||||
--color: #{$cyan};
|
||||
--card-border-color: #{rgba($white, .125)};
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Forma de ingreso a Sutty
|
||||
class ApplicationController < ActionController::Base
|
||||
include ExceptionHandler
|
||||
include ExceptionHandler if Rails.env.production?
|
||||
include Pundit::Authorization
|
||||
|
||||
protect_from_forgery with: :null_session, prepend: true
|
||||
|
@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
session[:usuarie_return_to] = request.fullpath
|
||||
end
|
||||
|
||||
# Detecta si una petición fue hecha por HTMX
|
||||
def htmx?
|
||||
request.headers.key? 'HX-Request'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
# No necesitamos autenticación aun
|
||||
class CollaborationsController < ApplicationController
|
||||
include Pundit
|
||||
include StrongParamsHelper
|
||||
|
||||
def collaborate
|
||||
@site = Site.find_by_name(params[:site_id])
|
||||
@site = Site.find_by_name(pluck_param(:site_id))
|
||||
authorize Collaboration.new(@site)
|
||||
|
||||
@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
|
||||
def accept_collaboration
|
||||
@site = Site.find_by_name(params[:site_id])
|
||||
@site = Site.find_by_name(pluck_param(:site_id))
|
||||
authorize Collaboration.new(@site)
|
||||
|
||||
@invitade = current_usuarie
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
# Controlador para artículos
|
||||
class PostsController < ApplicationController
|
||||
include StrongParamsHelper
|
||||
|
||||
before_action :authenticate_usuarie!
|
||||
before_action :service_for_direct_upload, only: %i[new edit]
|
||||
|
||||
|
@ -15,6 +17,82 @@ class PostsController < ApplicationController
|
|||
{ locale: locale }
|
||||
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
|
||||
authorize Post
|
||||
|
||||
|
@ -55,7 +133,7 @@ class PostsController < ApplicationController
|
|||
|
||||
def new
|
||||
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), ''
|
||||
end
|
||||
|
@ -65,13 +143,34 @@ class PostsController < ApplicationController
|
|||
service = PostService.new(site: site,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
@post = service.create
|
||||
@post = service.create_or_update
|
||||
|
||||
if @post.persisted?
|
||||
if post.persisted?
|
||||
site.touch
|
||||
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
|
||||
render 'posts/new'
|
||||
end
|
||||
|
@ -83,6 +182,16 @@ class PostsController < ApplicationController
|
|||
breadcrumb 'posts.edit', ''
|
||||
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
|
||||
authorize post
|
||||
|
||||
|
@ -94,7 +203,37 @@ class PostsController < ApplicationController
|
|||
if service.update.persisted?
|
||||
site.touch
|
||||
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)
|
||||
else
|
||||
render 'posts/edit'
|
||||
|
@ -168,4 +307,24 @@ class PostsController < ApplicationController
|
|||
def service_for_direct_upload
|
||||
session[:service_name] = site.name.to_sym
|
||||
end
|
||||
|
||||
# @param triggers [Hash] Otros disparadores
|
||||
def swap_modals(triggers = {})
|
||||
params.permit(:show, :hide).each_pair do |key, value|
|
||||
triggers["modal:#{key}"] = { id: value } 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
|
||||
|
|
|
@ -59,9 +59,6 @@ class StatsController < ApplicationController
|
|||
.order('sum(value) desc')
|
||||
.sum(:value)
|
||||
.transform_values(&:to_i)
|
||||
.transform_values do |v|
|
||||
v * nodes
|
||||
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|
|
||||
series.each do |serie|
|
||||
serie[:name] = serie.dig(:dimensions, 'host')
|
||||
serie[:data].transform_values! do |value|
|
||||
value * nodes
|
||||
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|
|
||||
series.each do |serie|
|
||||
serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/')
|
||||
serie[:data].transform_values! do |value|
|
||||
value * nodes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -197,21 +188,6 @@ class StatsController < ApplicationController
|
|||
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
|
||||
@period ||= begin
|
||||
p = params.permit(:period_start, :period_end)
|
||||
|
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
# Helpers
|
||||
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
|
||||
# esperan los helpers *_field
|
||||
#
|
||||
|
@ -19,6 +32,14 @@ module ApplicationHelper
|
|||
[root, name]
|
||||
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)
|
||||
root, name = field_name_for(*names)
|
||||
|
||||
|
@ -134,9 +155,17 @@ module ApplicationHelper
|
|||
|
||||
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:)
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) ||
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) ||
|
||||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}")
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence ||
|
||||
post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) ||
|
||||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence
|
||||
end
|
||||
end
|
||||
|
|
19
app/helpers/strong_params_helper.rb
Normal file
19
app/helpers/strong_params_helper.rb
Normal 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
|
138
app/javascript/controllers/array_controller.js
Normal file
138
app/javascript/controllers/array_controller.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [];
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button
|
||||
export default class extends Controller {
|
||||
|
|
10
app/javascript/controllers/enter_controller.js
Normal file
10
app/javascript/controllers/enter_controller.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import bsCustomFileInput from "bs-custom-file-input";
|
||||
|
||||
document.addEventListener("turbolinks:load", () => {
|
||||
|
|
53
app/javascript/controllers/form_validation_controller.js
Normal file
53
app/javascript/controllers/form_validation_controller.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
require("leaflet/dist/leaflet.css")
|
||||
import L from 'leaflet'
|
||||
|
|
107
app/javascript/controllers/htmx_controller.js
Normal file
107
app/javascript/controllers/htmx_controller.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
// Load all the controllers within this directory and all subdirectories.
|
||||
// Controller files must be named *_controller.js.
|
||||
|
||||
import { Application } from "stimulus"
|
||||
import { definitionsFromContext } from "stimulus/webpack-helpers"
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
|
||||
|
||||
const application = Application.start()
|
||||
const context = require.context("controllers", true, /_controller\.js$/)
|
||||
|
|
95
app/javascript/controllers/modal_controller.js
Normal file
95
app/javascript/controllers/modal_controller.js
Normal 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");
|
||||
}
|
||||
}
|
18
app/javascript/controllers/new_editor_controller.js
Normal file
18
app/javascript/controllers/new_editor_controller.js
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
require("leaflet/dist/leaflet.css")
|
||||
import L from 'leaflet'
|
||||
|
|
43
app/javascript/controllers/notification_controller.js
Normal file
43
app/javascript/controllers/notification_controller.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Permite reordenar las filas de una tabla.
|
||||
|
|
53
app/javascript/controllers/required_checkbox_controller.js
Normal file
53
app/javascript/controllers/required_checkbox_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["toggle", "input"];
|
||||
|
|
55
app/javascript/controllers/unsaved_changes_controller.js
Normal file
55
app/javascript/controllers/unsaved_changes_controller.js
Normal 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));
|
||||
}
|
||||
}
|
|
@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
|
|||
window.htmx.trigger(hx, "htmx:abort");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:resetForm", (event) => {
|
||||
event.target.reset();
|
||||
});
|
||||
|
|
|
@ -4,6 +4,4 @@ import './input-tag'
|
|||
import './prosemirror'
|
||||
import './timezone'
|
||||
import './turbolinks-anchors'
|
||||
import './validation'
|
||||
import './new_editor'
|
||||
import './htmx_abort'
|
||||
|
|
|
@ -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"),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -41,4 +41,5 @@ Rails.start()
|
|||
Turbolinks.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;
|
||||
|
|
12
app/lib/core_extensions/string/remove_diacritics.rb
Normal file
12
app/lib/core_extensions/string/remove_diacritics.rb
Normal 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
|
33
app/models/concerns/metadata/unused_values_concern.rb
Normal file
33
app/models/concerns/metadata/unused_values_concern.rb
Normal 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
|
|
@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
|
|||
true && !private?
|
||||
end
|
||||
|
||||
def titleize?
|
||||
true
|
||||
end
|
||||
|
||||
def to_s
|
||||
value.join(', ')
|
||||
value.select(&:present?).join(', ')
|
||||
end
|
||||
|
||||
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
|
||||
|
|
|
@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
''
|
||||
end
|
||||
|
||||
def to_s
|
||||
belongs_to.try(:title).try(:value).to_s
|
||||
end
|
||||
|
||||
# Obtiene el valor desde el documento.
|
||||
#
|
||||
# @return [String]
|
||||
|
@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
document.data[name.to_s]
|
||||
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
|
||||
# relación anterior si existía.
|
||||
def save
|
||||
|
@ -97,6 +93,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
end
|
||||
|
||||
def sanitize(uuid)
|
||||
uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
|
||||
uuid.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'htmlbeautifier'
|
||||
|
||||
# Se encarga del contenido del artículo y quizás otros campos que
|
||||
# requieran texto largo.
|
||||
class MetadataContent < MetadataTemplate
|
||||
|
@ -86,7 +88,7 @@ class MetadataContent < MetadataTemplate
|
|||
end
|
||||
end
|
||||
|
||||
html.to_s.html_safe
|
||||
HtmlBeautifier.beautify(html.to_s).html_safe
|
||||
end
|
||||
|
||||
# Limpia estilos en base a una lista de permitidos
|
||||
|
|
|
@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
|
|||
# XXX: Esto ayuda a deserializar en {Site#everything_of}
|
||||
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
|
||||
super
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
|
|||
return true unless changed?
|
||||
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?
|
||||
|
||||
true
|
||||
|
|
|
@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
|
|||
def save
|
||||
super
|
||||
|
||||
self[:value] = self[:value].uniq
|
||||
|
||||
return true unless changed?
|
||||
return true unless inverse?
|
||||
|
||||
(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
|
||||
end
|
||||
|
||||
|
|
5
app/models/metadata_new_array.rb
Normal file
5
app/models/metadata_new_array.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Implementa la nueva interfaz de gestión de valores
|
||||
class MetadataNewArray < MetadataArray
|
||||
end
|
6
app/models/metadata_new_belongs_to.rb
Normal file
6
app/models/metadata_new_belongs_to.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz
|
||||
class MetadataNewBelongsTo < MetadataBelongsTo
|
||||
include Metadata::UnusedValuesConcern
|
||||
end
|
4
app/models/metadata_new_has_and_belongs_to_many.rb
Normal file
4
app/models/metadata_new_has_and_belongs_to_many.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para relaciones muchos a muchos
|
||||
class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end
|
6
app/models/metadata_new_has_many.rb
Normal file
6
app/models/metadata_new_has_many.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Interfaz nueva para uno a muchos
|
||||
class MetadataNewHasMany < MetadataHasMany
|
||||
include Metadata::UnusedValuesConcern
|
||||
end
|
4
app/models/metadata_new_has_one.rb
Normal file
4
app/models/metadata_new_has_one.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para relaciones 1:1
|
||||
class MetadataNewHasOne < MetadataHasOne; end
|
4
app/models/metadata_new_predefined_array.rb
Normal file
4
app/models/metadata_new_predefined_array.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para arrays predefinidos
|
||||
class MetadataNewPredefinedArray < MetadataPredefinedArray; end
|
8
app/models/metadata_new_predefined_value.rb
Normal file
8
app/models/metadata_new_predefined_value.rb
Normal 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
|
|
@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
|
|||
[v[I18n.locale.to_s], k]
|
||||
end&.to_h
|
||||
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
|
||||
|
|
|
@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
|
|||
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
|
||||
end
|
||||
|
||||
def to_s
|
||||
values.invert[value].to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Solo permite almacenar los valores predefinidos.
|
||||
|
|
|
@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
|
|||
false
|
||||
end
|
||||
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
|
||||
def indexable_values
|
||||
posts.where(uuid: value).map(&:title).map(&:value)
|
||||
end
|
||||
|
||||
# Encuentra el filtro
|
||||
#
|
||||
# @return [Hash]
|
||||
def filter
|
||||
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Obtiene todos los posts y opcionalmente los filtra
|
||||
|
@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
|
|||
end
|
||||
|
||||
def title(post)
|
||||
"#{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 || {}
|
||||
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value&.strftime('%F')} (#{post.layout.humanized_name})"
|
||||
end
|
||||
|
||||
def sanitize(uuid)
|
||||
super(uuid.map do |u|
|
||||
u.to_s.gsub(/[^a-f0-9\-]/i, '')
|
||||
u.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,7 +25,7 @@ require 'jekyll/utils'
|
|||
class MetadataSlug < MetadataTemplate
|
||||
# Trae el slug desde el título si existe o una string al azar
|
||||
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
|
||||
|
||||
def value
|
||||
|
|
|
@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
|
|||
true && !private?
|
||||
end
|
||||
|
||||
def titleize?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# No se permite HTML en las strings
|
||||
|
|
|
@ -16,6 +16,11 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
false
|
||||
end
|
||||
|
||||
# El valor puede ser parte de un título auto-generado
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
||||
end
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Un campo de texto largo
|
||||
class MetadataText < MetadataString; end
|
||||
class MetadataText < MetadataString
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
26
app/models/metadata_title.rb
Normal file
26
app/models/metadata_title.rb
Normal 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
|
|
@ -12,9 +12,21 @@ class Post
|
|||
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
|
||||
# Otros atributos que no vienen en los metadatos
|
||||
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
|
||||
|
||||
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 UnknownAttributeError < PostError; end
|
||||
|
||||
|
@ -49,10 +61,12 @@ class Post
|
|||
@layout = args[:layout]
|
||||
@site = args[:site]
|
||||
@document = args[:document]
|
||||
@attributes = layout.attributes + PUBLIC_ATTRIBUTES
|
||||
@attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
|
||||
@errors = {}
|
||||
@metadata = {}
|
||||
|
||||
layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
|
||||
|
||||
# Leer el documento si existe
|
||||
# @todo Asignar todos los valores a self[:value] luego de leer
|
||||
document&.read! unless new?
|
||||
|
@ -127,6 +141,7 @@ class Post
|
|||
src = element.attributes['src']
|
||||
|
||||
next unless src&.value&.start_with? 'public/'
|
||||
|
||||
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
|
||||
file.value['path'] = src.value
|
||||
|
||||
|
@ -188,13 +203,13 @@ class Post
|
|||
def method_missing(name, *_args)
|
||||
# Limpiar el nombre del atributo, para que todos los ayudantes
|
||||
# reciban el método en limpio
|
||||
unless attribute? name
|
||||
raise NoMethodError, I18n.t('exceptions.post.no_method', method: name)
|
||||
end
|
||||
raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
|
||||
|
||||
define_singleton_method(name) do
|
||||
template = layout.metadata[name.to_s]
|
||||
|
||||
return public_send(template['alias'].to_sym) if template.key?('alias')
|
||||
|
||||
@metadata[name] ||=
|
||||
MetadataFactory.build(document: document,
|
||||
post: self,
|
||||
|
@ -210,55 +225,6 @@ class Post
|
|||
public_send name
|
||||
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.
|
||||
#
|
||||
# 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)
|
||||
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
|
||||
|
||||
# Levanta un error si al construir el artículo no pasamos un atributo.
|
||||
|
|
|
@ -3,26 +3,43 @@
|
|||
# Este servicio se encarga de crear artículos y guardarlos en git,
|
||||
# asignándoselos a une usuarie
|
||||
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
|
||||
#
|
||||
# @return Post
|
||||
def create
|
||||
self.post = site.posts(lang: locale)
|
||||
.build(layout: layout)
|
||||
self.post ||= site.posts(lang: locale).build(layout: layout)
|
||||
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)
|
||||
|
||||
params.require(:post).permit(:slug).tap do |p|
|
||||
params.require(base).permit(:slug).tap do |p|
|
||||
post.slug.value = p[:slug] if p[:slug].present?
|
||||
end
|
||||
|
||||
# Crea los posts anidados
|
||||
create_nested_posts! post, params[:post]
|
||||
create_nested_posts! post, params[base]
|
||||
post.save
|
||||
update_related_posts
|
||||
|
||||
commit(action: :created, add: files)
|
||||
commit(action: :created, add: files) if post.valid?
|
||||
|
||||
update_site_license!
|
||||
|
||||
|
@ -46,14 +63,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
|
||||
def update
|
||||
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.
|
||||
if post.update(post_params)
|
||||
rm = []
|
||||
rm << post.path.value_was if post.path.changed?
|
||||
|
||||
create_nested_posts! post, params[:post]
|
||||
create_nested_posts! post, params[base]
|
||||
update_related_posts
|
||||
|
||||
# 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 }
|
||||
def reorder
|
||||
reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
|
||||
reorder = params.require(base).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
|
||||
posts = site.posts(lang: locale).where(uuid: reorder.keys)
|
||||
|
||||
files = posts.map do |post|
|
||||
|
@ -105,6 +122,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
|
||||
private
|
||||
|
||||
# La base donde buscar los parámetros
|
||||
#
|
||||
# @return [Symbol]
|
||||
def base
|
||||
@base ||= params.permit(:base).try(:[], :base).try(:to_sym) || :post
|
||||
end
|
||||
|
||||
# Una lista de archivos a modificar
|
||||
#
|
||||
# @return [Set]
|
||||
|
@ -126,7 +150,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
|
||||
# Solo permitir cambiar estos atributos de cada articulo
|
||||
def post_params
|
||||
@post_params ||= params.require(:post).permit(post.params).to_h
|
||||
@post_params ||= params.require(base).permit(post.params).to_h
|
||||
end
|
||||
|
||||
# Eliminar metadatos internos
|
||||
|
@ -137,11 +161,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
end
|
||||
|
||||
def locale
|
||||
params.dig(:post, :lang)&.to_sym || I18n.locale
|
||||
params.dig(base, :lang)&.to_sym || I18n.locale
|
||||
end
|
||||
|
||||
def layout
|
||||
params.dig(:post, :layout) || params[:layout]
|
||||
params.dig(base, :layout) || params[:layout]
|
||||
end
|
||||
|
||||
# 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
|
||||
# personalizada en el panel.
|
||||
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')
|
||||
end
|
||||
end
|
||||
|
||||
# Encuentra todos los posts anidados y los crea o modifica
|
||||
def create_nested_posts!(post, params)
|
||||
post.nested_attributes.each do |nested_attribute|
|
||||
nested_metadata = post[nested_attribute]
|
||||
next unless params[nested_metadata].present?
|
||||
# @todo find_or_initialize
|
||||
nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested)
|
||||
nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash
|
||||
|
|
|
@ -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
|
||||
|
|
13
app/views/bootstrap/_btn.haml
Normal file
13
app/views/bootstrap/_btn.haml
Normal 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
|
8
app/views/bootstrap/_card.haml
Normal file
8
app/views/bootstrap/_card.haml
Normal 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
|
|
@ -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
|
||||
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
|
||||
.custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
|
||||
%input.custom-control-input{ **checkbox_attributes }
|
||||
%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
|
||||
|
|
44
app/views/bootstrap/_modal.haml
Normal file
44
app/views/bootstrap/_modal.haml
Normal 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')
|
4
app/views/bootstrap/_responsive.haml
Normal file
4
app/views/bootstrap/_responsive.haml
Normal file
|
@ -0,0 +1,4 @@
|
|||
- local_assigns[:ratio] ||= '1by1'
|
||||
|
||||
.embed-responsive{ class: "embed-responsive-#{local_assigns[:ratio]}" }
|
||||
.embed-responsive-item= yield
|
|
@ -18,7 +18,7 @@
|
|||
toggle: 'true',
|
||||
display: 'static',
|
||||
action: 'dropdown#toggle',
|
||||
target: 'dropdown.button'
|
||||
'dropdown-target': 'button'
|
||||
},
|
||||
aria: {
|
||||
expanded: 'false'
|
||||
|
@ -28,7 +28,7 @@
|
|||
.dropdown-menu{
|
||||
class: dropdown_classes,
|
||||
data: {
|
||||
target: 'dropdown.dropdown'
|
||||
'dropdown-target': 'dropdown'
|
||||
}
|
||||
}
|
||||
= yield
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
@param value [String]
|
||||
@param text [String]
|
||||
- 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
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
@param :text [String] Contenido del link
|
||||
@param :path [String,Hash] Link
|
||||
- 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' }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
-#
|
||||
@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')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
%hr/
|
||||
|
||||
- locale = params.permit(:locale)
|
||||
- locale = { locale: (pluck_param(:locale, optional: true) || I18n.locale) }
|
||||
|
||||
- if controller_name != 'sessions'
|
||||
= link_to t('.sign_in'), new_session_path(resource_name, params: locale),
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
.row.no-gutters.pt-2
|
||||
.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
|
||||
- cache [actor_moderation, profile] do
|
||||
%h4
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
.row.no-gutters
|
||||
.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
|
||||
- cache [activity_pub, comment] do
|
||||
.d-flex.flex-row.align-items-center.justify-content-between
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
.row.no-gutters.pt-2
|
||||
.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
|
||||
- cache [instance_moderation, instance] do
|
||||
%h4
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
@param post [Post]
|
||||
@param site [Site]
|
||||
@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]
|
||||
- type = metadata.type
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
- next if attribute == :date
|
||||
- next if attribute == :draft
|
||||
- next if attribute == inverse
|
||||
|
||||
- metadata = post[attribute]
|
||||
|
||||
- cache [post, metadata, I18n.locale] do
|
||||
|
|
19
app/views/posts/_errors.haml
Normal file
19
app/views/posts/_errors.haml
Normal 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
|
|
@ -1,22 +1,4 @@
|
|||
- 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
|
||||
= render 'errors', post: post
|
||||
|
||||
-# TODO: habilitar form_for
|
||||
:ruby
|
||||
|
@ -31,12 +13,19 @@
|
|||
end
|
||||
|
||||
- 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
|
||||
= 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
|
||||
= 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
|
||||
|
||||
|
@ -44,4 +33,15 @@
|
|||
= render 'posts/attributes', site: site, post: post, dir: dir, base: 'post', locale: @locale
|
||||
|
||||
-# 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' } }
|
||||
|
|
81
app/views/posts/_htmx_form.haml
Normal file
81
app/views/posts/_htmx_form.haml
Normal 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)
|
1
app/views/posts/_new_array_value.haml
Normal file
1
app/views/posts/_new_array_value.haml
Normal file
|
@ -0,0 +1 @@
|
|||
%li= value
|
2
app/views/posts/_new_has_one.haml
Normal file
2
app/views/posts/_new_has_one.haml
Normal file
|
@ -0,0 +1,2 @@
|
|||
= render 'posts/new_related_post', post: post, modal_id: modal_id
|
||||
%input{ type: 'hidden', name: name, value: value }
|
32
app/views/posts/_new_related_post.haml
Normal file
32
app/views/posts/_new_related_post.haml
Normal 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
|
19
app/views/posts/_required_checkbox.haml
Normal file
19
app/views/posts/_required_checkbox.haml
Normal 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]
|
|
@ -1,8 +1,3 @@
|
|||
- invalid_help = site.config.fetch('invalid_help', t('.invalid_help'))
|
||||
- sending_help = site.config.fetch('sending_help', t('.sending_help'))
|
||||
.form-group
|
||||
= 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
|
||||
.d-flex.flex-column.flex-md-row.align-items-start.mb-3
|
||||
%div= submit_tag t('.save'), class: 'btn btn-secondary submit-post'
|
||||
= render 'posts/validation', site: site, submitting: { id: submitting }, invalid: { id: invalid }
|
||||
|
|
16
app/views/posts/_validation.haml
Normal file
16
app/views/posts/_validation.haml
Normal 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
|
8
app/views/posts/attribute_ro/_new_array.haml
Normal file
8
app/views/posts/attribute_ro/_new_array.haml
Normal 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
|
6
app/views/posts/attribute_ro/_new_belongs_to.haml
Normal file
6
app/views/posts/attribute_ro/_new_belongs_to.haml
Normal 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)
|
|
@ -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)
|
6
app/views/posts/attribute_ro/_new_has_many.haml
Normal file
6
app/views/posts/attribute_ro/_new_has_many.haml
Normal 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)
|
6
app/views/posts/attribute_ro/_new_has_one.haml
Normal file
6
app/views/posts/attribute_ro/_new_has_one.haml
Normal 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)
|
5
app/views/posts/attribute_ro/_new_predefined_array.haml
Normal file
5
app/views/posts/attribute_ro/_new_predefined_array.haml
Normal 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
|
3
app/views/posts/attribute_ro/_new_predefined_value.haml
Normal file
3
app/views/posts/attribute_ro/_new_predefined_value.haml
Normal file
|
@ -0,0 +1,3 @@
|
|||
%tr{ id: attribute }
|
||||
%th= post_label_t(attribute, post: post)
|
||||
%td{ dir: dir, lang: locale }= metadata.to_s
|
|
@ -1,3 +1,3 @@
|
|||
%tr{ id: attribute }
|
||||
%th= post_label_t(attribute, post: post)
|
||||
%td{ dir: dir, lang: locale }= metadata.value
|
||||
%td{ dir: dir, lang: locale }= metadata.to_s
|
||||
|
|
3
app/views/posts/attribute_ro/_title.haml
Normal file
3
app/views/posts/attribute_ro/_title.haml
Normal file
|
@ -0,0 +1,3 @@
|
|||
%tr{ id: attribute }
|
||||
%th= post_label_t(attribute, post: post)
|
||||
%td{ dir: dir, lang: locale }= metadata.value
|
|
@ -1,5 +1,5 @@
|
|||
.form-check
|
||||
= hidden_field_tag "#{base}[#{attribute}]", '0', id: ''
|
||||
= hidden_field_tag "#{base}[#{attribute}]", '0', id: nil
|
||||
.custom-control.custom-switch
|
||||
= check_box_tag "#{base}[#{attribute}]", '1', metadata.value,
|
||||
class: "custom-control-input #{invalid(post, attribute)}",
|
||||
|
|
|
@ -4,11 +4,11 @@
|
|||
- when %r{\Avideo/}
|
||||
= video_tag url_for(metadata.static_file),
|
||||
controls: true, class: 'img-fluid',
|
||||
data: { target: 'file-preview.preview' }
|
||||
data: { 'file-preview-target': 'preview' }
|
||||
- when %r{\Aaudio/}
|
||||
= audio_tag url_for(metadata.static_file),
|
||||
controls: true, class: 'img-fluid',
|
||||
data: { target: 'file-preview.preview' }
|
||||
data: { 'file-preview-target': 'preview' }
|
||||
- when 'application/pdf'
|
||||
%iframe{ src: url_for(metadata.static_file) }
|
||||
- else
|
||||
|
@ -26,7 +26,8 @@
|
|||
= file_field(*field_name_for(base, attribute, :path),
|
||||
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
|
||||
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",
|
||||
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
|
||||
= render 'posts/attribute_feedback',
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
= text_field(*field_name_for(base, attribute, :lat),
|
||||
value: metadata.value['lat'],
|
||||
**field_options(attribute, metadata),
|
||||
data: { target: 'geo.lat' })
|
||||
data: { 'geo-target': 'lat' })
|
||||
= render 'posts/attribute_feedback',
|
||||
post: post, attribute: [attribute, :lat], metadata: metadata
|
||||
.col
|
||||
|
@ -20,8 +20,8 @@
|
|||
= text_field(*field_name_for(base, attribute, :lng),
|
||||
value: metadata.value['lng'],
|
||||
**field_options(attribute, metadata),
|
||||
data: { target: 'geo.lng' })
|
||||
data: { 'geo-target': 'lng' })
|
||||
= render 'posts/attribute_feedback',
|
||||
post: post, attribute: [attribute, :lng], metadata: metadata
|
||||
.col-12.mb-3
|
||||
%div{ data: { target: 'geo.map' }, style: 'height: 250px' }
|
||||
%div{ data: { 'geo-target': 'map' }, style: 'height: 250px' }
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
= image_tag url_for(metadata.static_file),
|
||||
alt: metadata.value['description'],
|
||||
class: 'img-fluid',
|
||||
data: { target: 'file-preview.preview' }
|
||||
data: { 'file-preview-target': 'preview' }
|
||||
|
||||
-# Mantener el valor si no enviamos ninguna imagen
|
||||
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
|
||||
|
@ -16,14 +16,15 @@
|
|||
= image_tag '',
|
||||
alt: metadata.value['description'],
|
||||
class: 'img-fluid',
|
||||
data: { target: 'file-preview.preview' }
|
||||
data: { 'file-preview-target': 'preview' }
|
||||
|
||||
.custom-file
|
||||
= file_field(*field_name_for(base, attribute, :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(','),
|
||||
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",
|
||||
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
|
||||
= render 'posts/attribute_feedback',
|
||||
|
|
71
app/views/posts/attributes/_new_array.haml
Normal file
71
app/views/posts/attributes/_new_array.haml
Normal 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 }
|
111
app/views/posts/attributes/_new_belongs_to.haml
Normal file
111
app/views/posts/attributes/_new_belongs_to.haml
Normal 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'
|
|
@ -3,7 +3,7 @@
|
|||
= render 'posts/attribute_feedback',
|
||||
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,
|
||||
dir: dir, lang: locale,
|
||||
dir: dir, lang: locale, 'data-new-editor-target': 'textarea',
|
||||
**field_options(attribute, metadata), class: 'd-none'
|
||||
|
|
113
app/views/posts/attributes/_new_has_and_belongs_to_many.haml
Normal file
113
app/views/posts/attributes/_new_has_and_belongs_to_many.haml
Normal 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'
|
113
app/views/posts/attributes/_new_has_many.haml
Normal file
113
app/views/posts/attributes/_new_has_many.haml
Normal 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
Loading…
Reference in a new issue