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

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

Draft: new array #15068

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
// Load all the controllers within this directory and all subdirectories.
// 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$/)

View file

@ -0,0 +1,95 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["modal", "backdrop"];
// TODO: Stimulus >1
connect() {
this.showEvent = this.show.bind(this);
this.hideEvent = this.hide.bind(this);
window.addEventListener("modal:show", this.showEvent);
window.addEventListener("modal:hide", this.hideEvent);
}
// TODO: Stimulus >1
disconnect() {
window.removeEventListener("modal:show", this.showEvent);
window.removeEventListener("modal:hide", this.hideEvent);
}
/*
* Abrir otro modal, enviando el ID a toda la ventana.
*/
showAnother(event = undefined) {
event?.preventDefault();
if (!event.target?.dataset?.modalShowValue) return;
window.dispatchEvent(new CustomEvent("modal:show", { detail: { id: event.target.dataset.modalShowValue, previousFocus: event.target.id } }));
}
/*
* Podemos enviar la orden de apertura como un click o como un
* CustomEvent incluyendo el id del modal como detail.
*
* El elemento clicleable puede tener un valor que se refiera a otro
* modal también.
*/
show(event = undefined) {
event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.modalTarget.style.display = "block";
this.backdropTarget.style.display = "block";
this.modalTarget.setAttribute("role", "dialog");
this.modalTarget.setAttribute("aria-modal", true);
this.modalTarget.removeAttribute("aria-hidden");
window.document.body.classList.add("modal-open");
if (event?.detail?.previousFocus) {
this.previousFocus = window.document.getElementById(event.detail.previousFocus);
} else {
this.previousFocus = event?.target;
}
setTimeout(() => {
this.modalTarget.classList.add("show");
this.backdropTarget.classList.add("show");
this.modalTarget.focus();
}, 1);
}
hideWithEscape(event) {
if (event?.key !== "Escape") return;
this.hide();
}
hide(event = undefined) {
event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.backdropTarget.classList.remove("show");
this.modalTarget.classList.remove("show");
this.modalTarget.setAttribute("aria-hidden", true);
this.modalTarget.removeAttribute("role");
this.modalTarget.removeAttribute("aria-modal");
this.previousFocus?.focus();
setTimeout(() => {
this.modalTarget.style.display = "";
this.backdropTarget.style.display = "";
}, 500);
window.document.body.classList.remove("modal-open");
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
true && !private?
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
[v[I18n.locale.to_s], k]
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,9 +12,21 @@ class Post
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# 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.

View file

@ -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,24 +197,25 @@ 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?
site.update licencia: Licencia.find_by_icons('custom')
end
return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom')
end
# Encuentra todos los posts anidados y los crea o modifica
def create_nested_posts!(post, params)
post.nested_attributes.each do |nested_attribute|
nested_metadata = post[nested_attribute]
# @todo find_or_initialize
nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested)
nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash
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
# Completa la relación 1:1
nested_params[nested_metadata.inverse.to_s] = post.uuid.value
post[nested_attribute].value = nested_post.uuid.value
# Completa la relación 1:1
nested_params[nested_metadata.inverse.to_s] = post.uuid.value
post[nested_attribute].value = nested_post.uuid.value
files << nested_post.path.absolute if nested_post.update(nested_params)
files << nested_post.path.absolute if nested_post.update(nested_params)
end
end
end

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
- help_id = "#{id}_help"
:ruby
help_id = "#{id}_help"
checkbox_attributes = local_assigns.slice(:id, :type, :name, :value, :required, :checked, :data, :disabled)
checkbox_attributes[:type] ||= 'checkbox'
.custom-control.custom-checkbox
%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

View file

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

View file

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

View file

@ -18,7 +18,7 @@
toggle: 'true',
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,13 +4,14 @@
@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
- cache [metadata, I18n.locale] do
= render("posts/attributes/#{type}",
base: base, post: post, attribute: attribute,
metadata: metadata, site: site,
dir: dir, locale: locale,
autofocus: (post.attributes.first == attribute))
base: base, post: post, attribute: attribute,
metadata: metadata, site: site,
dir: dir, locale: locale,
autofocus: (post.attributes.first == attribute))

View file

@ -9,10 +9,11 @@
- next if attribute == :date
- next if attribute == :draft
- next if attribute == inverse
- metadata = post[attribute]
- cache [post, metadata, I18n.locale] do
= render "posts/attributes/#{metadata.type}",
base: base, post: post, attribute: attribute,
metadata: metadata, site: site,
dir: dir, locale: locale, autofocus: false
base: base, post: post, attribute: attribute,
metadata: metadata, site: site,
dir: dir, locale: locale, autofocus: false

View file

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

View file

@ -1,22 +1,4 @@
- unless post.errors.empty?
- 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' } }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,3 @@
- invalid_help = site.config.fetch('invalid_help', t('.invalid_help'))
- 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 }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
.form-check
= 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)}",

View file

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

View file

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

View file

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

View file

@ -0,0 +1,71 @@
-#
Genera un listado de checkboxes entre los que se puede elegir para guardar
:ruby
id = id_for(base, attribute)
name = "#{base}[#{attribute}][]"
form_id = random_id
controllers = %w[modal array enter]
controllers << 'required-checkbox' if metadata.required
%div{ data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.mb-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group.mb-0
-#
Si la lista es obligatoria, al menos uno de los ítems tiene que
estar activado. Logramos esto con un checkbox oculto que se marca
como obligatorio al validar el formulario.
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0 h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# Mostramos la lista de valores actuales.
Al aceptar el modal, se vacía el listado y se completa en base a
renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
acceder a todos los items dentro del modal (como array.item) y
enviar el valor al endpoint que devuelve uno por uno. Esto lo
tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
similar para poder renderizar del lado del servidor.
Para poder cancelar, mantenemos el estado original y desactivamos
o activamos los ítemes según estén incluidos en esa lista o no.
%ul.placeholder-glow{ data: { 'array-target': 'current' } }
- metadata.value.each do |value|
= render 'posts/new_array_value', value: value
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
%small.feedback.form-text.text-muted.mt-0.mb-1= post_help_t(metadata.name, post: post)
%input.form-control{ data: { 'array-target': 'search', action: 'input->array#search keydown->enter#prevent' }, type: 'search', placeholder: t('.filter') }
- content_for :"#{id}_body" do
.form-group.mb-0{ id: "#{id}_body" }
-# Eliminamos las tildes para poder buscar independientemente de cómo se escriba.
- metadata.values.each do |value|
= render 'targets/array/item', value: value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: value, checked: metadata.value.include?(value), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
- content_for :"#{id}_footer" do
.input-group.w-auto.flex-grow-1.my-0
%input.form-control{ form: form_id, name: 'value', type: 'text', placeholder: t('.add_new'), required: true }
.input-group-append
= render 'bootstrap/btn', content: t('.add', layout: ''), form: form_id, type: 'submit', class: 'mb-0 mr-0'
= render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
= render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
-# Los formularios para HTMX se colocan por fuera del formulario
principal, porque HTML5 no soporta formularios anidados. Los campos
quedan unidos al formulario por su atributo `id`.
Al enviar el formulario se obtiene una nueva opción con el valor
y se la agrega al final del listado.
- content_for :post_form do
%form{ id: form_id, 'hx-get': site_posts_new_array_path(site), 'hx-target': "##{id}_body", 'hx-swap': 'beforeend' }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'id', value: form_id }

View file

@ -0,0 +1,111 @@
-#
Genera un listado de radios entre los que se puede elegir solo uno para
guardar. Podemos elegir entre los artículos ya cargados o agregar uno
nuevo.
Al agregar uno nuevo, se abre un segundo modal que carga el formulario
correspondiente vía HTMX. El formulario tiene que cargarse por fuera
del formulario principal porque no se pueden anidar.
:ruby
id = random_id
name = "#{base}[#{attribute}]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'mb-0'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?, type: 'radio'
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# Mostramos la lista de valores actuales.
Al aceptar el modal, se vacía el listado y se completa en base a
renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
acceder a todos los items dentro del modal (como array.item) y
enviar el valor al endpoint que devuelve uno por uno. Esto lo
tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
similar para poder renderizar del lado del servidor.
Para poder cancelar, mantenemos el estado original y desactivamos
o activamos los ítemes según estén incluidos en esa lista o no.
.row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- if !metadata.empty? && (indexed_post = site.indexed_posts.find_by(post_id: metadata.value))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post)
%input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
- content_for :"#{id}_body" do
.form-group.mb-0{ id: value_list_id }
- metadata.values.each do |(value, uuid, disabled)|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, type: 'radio', disabled: disabled do
= t('posts.attributes.new_belongs_to.disabled') if disabled
-#
Según la definición del campo, si hay un filtro, tenemos que poder
elegir qué tipo de esquema queremos o si hay uno solo, siempre
vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
entre todos los esquemas.
- content_for :"#{id}_footer" do
- layout = metadata.filter[:layout]
- if layout.is_a?(String)
%input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
= render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
- else
- layouts = layout&.map { |x| site.layouts[x] }
- layouts ||= site.layouts.values
.input-group.w-auto.flex-grow-1.my-0
%select.form-control{ form: post_form_id, name: 'layout' }
- layouts.each do |layout|
%option{ value: layout.name }= layout.humanized_name
.input-group-append
= render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
= render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
= render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
-#
Este segundo modal es el que carga los formularios de
creación/modificación de artículos relacionados. Se envía a post_form
para que sea externo al formulario actual.
- content_for :post_form do
%form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
%input{ type: 'hidden', name: 'show', value: post_modal_id }
%input{ type: 'hidden', name: 'hide', value: modal_id }
%input{ type: 'hidden', name: 'target', value: value_list_id }
%input{ type: 'hidden', name: 'swap', value: 'beforeend' }
%input{ type: 'hidden', name: 'base', value: id }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%div{ id: post_modal_id, data: { controller: 'modal' } }
= render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
- content_for :"#{post_id}_body" do
%div{ id: post_form_loaded_id }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

View file

@ -3,7 +3,7 @@
= render 'posts/attribute_feedback',
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'

View file

@ -0,0 +1,113 @@
-#
Genera un listado de checkboxes entre los que se puede elegir para
guardar. Podemos elegir entre los artículos ya cargados o agregar uno
nuevo.
Al agregar uno nuevo, se abre un segundo modal que carga el formulario
correspondiente vía HTMX. El formulario tiene que cargarse por fuera
del formulario principal porque no se pueden anidar.
:ruby
id = random_id
name = "#{base}[#{attribute}][]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site, inverse: metadata.inverse) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
= hidden_field_tag name, ''
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# Mostramos la lista de valores actuales.
Al aceptar el modal, se vacía el listado y se completa en base a
renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
acceder a todos los items dentro del modal (como array.item) y
enviar el valor al endpoint que devuelve uno por uno. Esto lo
tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
similar para poder renderizar del lado del servidor.
Para poder cancelar, mantenemos el estado original y desactivamos
o activamos los ítemes según estén incluidos en esa lista o no.
.row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- metadata.value.each do |uuid|
- if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post)
%input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
- content_for :"#{id}_body" do
.form-group.mb-0{ id: value_list_id }
- metadata.values.each_pair do |value, uuid|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
-#
Según la definición del campo, si hay un filtro, tenemos que poder
elegir qué tipo de esquema queremos o si hay uno solo, siempre
vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
entre todos los esquemas.
- content_for :"#{id}_footer" do
- layout = metadata.filter[:layout]
- if layout.is_a?(String)
%input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
= render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
- else
- layouts = layout&.map { |x| site.layouts[x] }
- layouts ||= site.layouts.values
.input-group.w-auto.flex-grow-1.my-0
%select.form-control{ form: post_form_id, name: 'layout' }
- layouts.each do |layout|
%option{ value: layout.name }= layout.humanized_name
.input-group-append
= render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
= render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
= render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
-#
Este segundo modal es el que carga los formularios de
creación/modificación de artículos relacionados. Se envía a post_form
para que sea externo al formulario actual.
- content_for :post_form do
%form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
%input{ type: 'hidden', name: 'modal_id', value: modal_id }
%input{ type: 'hidden', name: 'show', value: post_modal_id }
%input{ type: 'hidden', name: 'hide', value: modal_id }
%input{ type: 'hidden', name: 'target', value: value_list_id }
%input{ type: 'hidden', name: 'swap', value: 'beforeend' }
%input{ type: 'hidden', name: 'base', value: id }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%div{ id: post_modal_id, data: { controller: 'modal' } }
= render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
- content_for :"#{post_id}_body" do
%div{ id: post_form_loaded_id }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

View file

@ -0,0 +1,113 @@
-#
Genera un listado de checkboxes entre los que se puede elegir para
guardar. Podemos elegir entre los artículos ya cargados o agregar uno
nuevo.
Al agregar uno nuevo, se abre un segundo modal que carga el formulario
correspondiente vía HTMX. El formulario tiene que cargarse por fuera
del formulario principal porque no se pueden anidar.
:ruby
id = id_for(base, attribute)
name = "#{base}[#{attribute}][]"
form_id = random_id
modal_id = random_id
post_id = random_id
post_form_id = random_id
post_modal_id = random_id
post_form_loaded_id = random_id
value_list_id = random_id
controllers = %w[modal array]
controllers << 'required-checkbox' if metadata.required
%div{ id: modal_id, data: { controller: controllers.join(' '), 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_related_post_path(site) } }
%template{ data: { 'array-target': 'placeholder' } }
.col.p-3{ 'aria-hidden': 'true' }
%span.placeholder.w-100
.form-group
.d-flex.align-items-center.justify-content-between
%div
= label_tag id, post_label_t(attribute, post: post), class: 'h3'
= render 'posts/required_checkbox', required: metadata.required, name: name, initial: metadata.empty?
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
-# Mostramos la lista de valores actuales.
Al aceptar el modal, se vacía el listado y se completa en base a
renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
acceder a todos los items dentro del modal (como array.item) y
enviar el valor al endpoint que devuelve uno por uno. Esto lo
tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
similar para poder renderizar del lado del servidor.
Para poder cancelar, mantenemos el estado original y desactivamos
o activamos los ítemes según estén incluidos en esa lista o no.
.row.no-gutters.placeholder-glow{ data: { 'array-target': 'current' } }
-# @todo issue-7537
- metadata.value.each do |uuid|
- if (indexed_post = site.indexed_posts.find_by(post_id: uuid))
= render 'posts/new_related_post', post: indexed_post, attribute: metadata.type, inverse: metadata.inverse
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'], keydown_actions: %w[keydown->array#cancelWithEscape] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post)
%input.form-control{ data: { 'array-target': 'search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
- content_for :"#{id}_body" do
.form-group.mb-0{ id: value_list_id }
- metadata.values.each do |(value, uuid, disabled)|
= render 'targets/array/item', value: uuid, 'send-value': uuid, 'human-value': value, class: 'mb-2' do
= render 'bootstrap/custom_checkbox', name: name, id: random_id, value: uuid, checked: metadata.value.include?(uuid), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }, disabled: disabled do
= t('posts.attributes.new_has_many.disabled') if disabled
-#
Según la definición del campo, si hay un filtro, tenemos que poder
elegir qué tipo de esquema queremos o si hay uno solo, siempre
vamos a enviar ese. Si no hay ninguno, tendríamos que poder elegir
entre todos los esquemas.
- content_for :"#{id}_footer" do
- layout = metadata.filter[:layout]
- if layout.is_a?(String)
%input{ type: 'hidden', name: 'layout', value: layout, form: post_form_id }
= render 'bootstrap/btn', content: t('.add', layout: site.layouts[layout].humanized_name), form: post_form_id, type: 'submit', class: 'm-0 mr-1'
- else
- layouts = layout&.map { |x| site.layouts[x] }
- layouts ||= site.layouts.values
.input-group.w-auto.flex-grow-1.my-0
%select.form-control{ form: post_form_id, name: 'layout' }
- layouts.each do |layout|
%option{ value: layout.name }= layout.humanized_name
.input-group-append
= render 'bootstrap/btn', content: t('.add', layout: ''), form: post_form_id, type: 'submit', class: 'mb-0 mr-0'
= render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
= render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
-#
Este segundo modal es el que carga los formularios de
creación/modificación de artículos relacionados. Se envía a post_form
para que sea externo al formulario actual.
- content_for :post_form do
%form{ id: post_form_id, 'hx-get': site_posts_form_path(site), 'hx-target': "##{post_form_loaded_id}" }
%input{ type: 'hidden', name: 'show', value: post_modal_id }
%input{ type: 'hidden', name: 'hide', value: modal_id }
%input{ type: 'hidden', name: 'target', value: value_list_id }
%input{ type: 'hidden', name: 'swap', value: 'beforeend' }
%input{ type: 'hidden', name: 'base', value: id }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'form', value: form_id }
%input{ type: 'hidden', name: 'attribute', value: metadata.type }
-# @todo Forma genérica de arrastrar valores desde un formulario al siguiente
- if metadata.inverse?
%input{ type: 'hidden', name: 'inverse', value: metadata.inverse }
%input{ type: 'hidden', name: metadata.inverse, value: post.uuid.value }
%div{ id: post_modal_id, data: { controller: 'modal' } }
= render 'bootstrap/modal', id: post_id, modal_content_attributes: { class: 'h-100' } do
- content_for :"#{post_id}_body" do
%div{ id: post_form_loaded_id }
- content_for :"#{post_id}_footer" do
= render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit'
-# @todo: Volver al otro modal
= render 'bootstrap/btn', content: t('.cancel'), action: 'modal#hide'

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