diff --git a/Gemfile b/Gemfile
index 5714b981..3677d738 100644
--- a/Gemfile
+++ b/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'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7f5284ef..2adf2f1b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 4d1d0848..6efb5ab8 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -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,"));
+
+ .custom-checkbox {
+ .custom-control-input:checked ~ .custom-control-label {
+ &::after {
+ background-image: url("data:image/svg+xml,");
+ }
+ }
+ }
+}
@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;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/dark.scss b/app/assets/stylesheets/dark.scss
index f7f3a09d..a5a1f837 100644
--- a/app/assets/stylesheets/dark.scss
+++ b/app/assets/stylesheets/dark.scss
@@ -6,6 +6,7 @@ $cyan: #13fefe;
--foreground: #{$white};
--background: #{$black};
--color: #{$cyan};
+ --card-border-color: #{rgba($white, .125)};
}
.btn {
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 117be995..0fc2440f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -2,7 +2,7 @@
# Forma de ingreso a Sutty
class ApplicationController < ActionController::Base
- include ExceptionHandler
+ include ExceptionHandler if Rails.env.production?
include Pundit::Authorization
protect_from_forgery with: :null_session, prepend: true
@@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
session[:usuarie_return_to] = request.fullpath
end
+
+ # Detecta si una petición fue hecha por HTMX
+ def htmx?
+ request.headers.key? 'HX-Request'
+ end
end
diff --git a/app/controllers/collaborations_controller.rb b/app/controllers/collaborations_controller.rb
index 2caa1272..8140b03e 100644
--- a/app/controllers/collaborations_controller.rb
+++ b/app/controllers/collaborations_controller.rb
@@ -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
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 057c3068..4aa2c8d0 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -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
diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb
index c2c7bc58..bc5bb962 100644
--- a/app/controllers/stats_controller.rb
+++ b/app/controllers/stats_controller.rb
@@ -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)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fcbd4074..3d074aed 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -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
diff --git a/app/helpers/strong_params_helper.rb b/app/helpers/strong_params_helper.rb
new file mode 100644
index 00000000..f248cc50
--- /dev/null
+++ b/app/helpers/strong_params_helper.rb
@@ -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
diff --git a/app/javascript/controllers/array_controller.js b/app/javascript/controllers/array_controller.js
new file mode 100644
index 00000000..5540e839
--- /dev/null
+++ b/app/javascript/controllers/array_controller.js
@@ -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,
+ );
+ }
+}
diff --git a/app/javascript/controllers/details_controller.js b/app/javascript/controllers/details_controller.js
index 57935e1e..170f482e 100644
--- a/app/javascript/controllers/details_controller.js
+++ b/app/javascript/controllers/details_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from "stimulus";
+import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [];
diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js
index e2b657fd..bbcc7aec 100644
--- a/app/javascript/controllers/dropdown_controller.js
+++ b/app/javascript/controllers/dropdown_controller.js
@@ -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 {
diff --git a/app/javascript/controllers/enter_controller.js b/app/javascript/controllers/enter_controller.js
new file mode 100644
index 00000000..7f851e8a
--- /dev/null
+++ b/app/javascript/controllers/enter_controller.js
@@ -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();
+ }
+}
diff --git a/app/javascript/controllers/file_preview_controller.js b/app/javascript/controllers/file_preview_controller.js
index 9eaaab2d..58c60496 100644
--- a/app/javascript/controllers/file_preview_controller.js
+++ b/app/javascript/controllers/file_preview_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from 'stimulus'
+import { Controller } from '@hotwired/stimulus'
import bsCustomFileInput from "bs-custom-file-input";
document.addEventListener("turbolinks:load", () => {
diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js
new file mode 100644
index 00000000..5672a69b
--- /dev/null
+++ b/app/javascript/controllers/form_validation_controller.js
@@ -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;
+ }
+}
diff --git a/app/javascript/controllers/geo_controller.js b/app/javascript/controllers/geo_controller.js
index 0018b01a..db3dab59 100644
--- a/app/javascript/controllers/geo_controller.js
+++ b/app/javascript/controllers/geo_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from 'stimulus'
+import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css")
import L from 'leaflet'
diff --git a/app/javascript/controllers/htmx_controller.js b/app/javascript/controllers/htmx_controller.js
new file mode 100644
index 00000000..e7bba7f9
--- /dev/null
+++ b/app/javascript/controllers/htmx_controller.js
@@ -0,0 +1,107 @@
+import { Controller } from "@hotwired/stimulus";
+
+/*
+ * Un controlador que imita a HTMX
+ */
+export default class extends Controller {
+ connect() {
+ // @todo Convertir en
+ this.placeholder = "";
+ }
+
+ /*
+ * 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]
+ */
+ 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);
+ }
+}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index 6f53d84b..8d0381e5 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -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$/)
diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js
new file mode 100644
index 00000000..8b2406a3
--- /dev/null
+++ b/app/javascript/controllers/modal_controller.js
@@ -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");
+ }
+}
diff --git a/app/javascript/controllers/new_editor_controller.js b/app/javascript/controllers/new_editor_controller.js
new file mode 100644
index 00000000..82af0fa9
--- /dev/null
+++ b/app/javascript/controllers/new_editor_controller.js
@@ -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,
+ },
+ });
+ }
+}
diff --git a/app/javascript/controllers/non_geo_controller.js b/app/javascript/controllers/non_geo_controller.js
index 1c618fcb..c9cbbef0 100644
--- a/app/javascript/controllers/non_geo_controller.js
+++ b/app/javascript/controllers/non_geo_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from 'stimulus'
+import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css")
import L from 'leaflet'
diff --git a/app/javascript/controllers/notification_controller.js b/app/javascript/controllers/notification_controller.js
new file mode 100644
index 00000000..a1590672
--- /dev/null
+++ b/app/javascript/controllers/notification_controller.js
@@ -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);
+ }
+}
diff --git a/app/javascript/controllers/reorder_controller.js b/app/javascript/controllers/reorder_controller.js
index 2cba4163..2e851c32 100644
--- a/app/javascript/controllers/reorder_controller.js
+++ b/app/javascript/controllers/reorder_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from 'stimulus'
+import { Controller } from '@hotwired/stimulus'
/*
* Permite reordenar las filas de una tabla.
diff --git a/app/javascript/controllers/required_checkbox_controller.js b/app/javascript/controllers/required_checkbox_controller.js
new file mode 100644
index 00000000..eb0050fe
--- /dev/null
+++ b/app/javascript/controllers/required_checkbox_controller.js
@@ -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;
+ }
+ }
+}
diff --git a/app/javascript/controllers/select_all_controller.js b/app/javascript/controllers/select_all_controller.js
index 7aca0f59..8d17209f 100644
--- a/app/javascript/controllers/select_all_controller.js
+++ b/app/javascript/controllers/select_all_controller.js
@@ -1,4 +1,4 @@
-import { Controller } from "stimulus";
+import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["toggle", "input"];
diff --git a/app/javascript/controllers/unsaved_changes_controller.js b/app/javascript/controllers/unsaved_changes_controller.js
new file mode 100644
index 00000000..e7c03627
--- /dev/null
+++ b/app/javascript/controllers/unsaved_changes_controller.js
@@ -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));
+ }
+}
diff --git a/app/javascript/etc/htmx_abort.js b/app/javascript/etc/htmx_abort.js
index 308d0315..75e497ba 100644
--- a/app/javascript/etc/htmx_abort.js
+++ b/app/javascript/etc/htmx_abort.js
@@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
window.htmx.trigger(hx, "htmx:abort");
}
});
+
+document.addEventListener("htmx:resetForm", (event) => {
+ event.target.reset();
+});
diff --git a/app/javascript/etc/index.js b/app/javascript/etc/index.js
index 3a1ef75c..7dd3671b 100644
--- a/app/javascript/etc/index.js
+++ b/app/javascript/etc/index.js
@@ -4,6 +4,4 @@ import './input-tag'
import './prosemirror'
import './timezone'
import './turbolinks-anchors'
-import './validation'
-import './new_editor'
import './htmx_abort'
diff --git a/app/javascript/etc/new_editor.js b/app/javascript/etc/new_editor.js
deleted file mode 100644
index dbc87bbc..00000000
--- a/app/javascript/etc/new_editor.js
+++ /dev/null
@@ -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"),
- },
- });
- });
-});
diff --git a/app/javascript/etc/validation.js b/app/javascript/etc/validation.js
deleted file mode 100644
index 5a48148f..00000000
--- a/app/javascript/etc/validation.js
+++ /dev/null
@@ -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')
- })
- })
-})
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index e10e2b5d..a0f18024 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -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;
diff --git a/app/lib/core_extensions/string/remove_diacritics.rb b/app/lib/core_extensions/string/remove_diacritics.rb
new file mode 100644
index 00000000..679db13d
--- /dev/null
+++ b/app/lib/core_extensions/string/remove_diacritics.rb
@@ -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
diff --git a/app/models/concerns/metadata/unused_values_concern.rb b/app/models/concerns/metadata/unused_values_concern.rb
new file mode 100644
index 00000000..a6f8aa54
--- /dev/null
+++ b/app/models/concerns/metadata/unused_values_concern.rb
@@ -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
diff --git a/app/models/metadata_array.rb b/app/models/metadata_array.rb
index 368aa546..a8a6e555 100644
--- a/app/models/metadata_array.rb
+++ b/app/models/metadata_array.rb
@@ -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
diff --git a/app/models/metadata_belongs_to.rb b/app/models/metadata_belongs_to.rb
index be1fa670..c5c36b50 100644
--- a/app/models/metadata_belongs_to.rb
+++ b/app/models/metadata_belongs_to.rb
@@ -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
diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb
index 761518e8..444ee2fe 100644
--- a/app/models/metadata_content.rb
+++ b/app/models/metadata_content.rb
@@ -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
diff --git a/app/models/metadata_file.rb b/app/models/metadata_file.rb
index 61aea897..fb5082f4 100644
--- a/app/models/metadata_file.rb
+++ b/app/models/metadata_file.rb
@@ -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
diff --git a/app/models/metadata_geo.rb b/app/models/metadata_geo.rb
index ba11f337..d475edf9 100644
--- a/app/models/metadata_geo.rb
+++ b/app/models/metadata_geo.rb
@@ -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
diff --git a/app/models/metadata_has_many.rb b/app/models/metadata_has_many.rb
index 13f0dcf5..a15f1241 100644
--- a/app/models/metadata_has_many.rb
+++ b/app/models/metadata_has_many.rb
@@ -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
diff --git a/app/models/metadata_new_array.rb b/app/models/metadata_new_array.rb
new file mode 100644
index 00000000..65993be2
--- /dev/null
+++ b/app/models/metadata_new_array.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+# Implementa la nueva interfaz de gestión de valores
+class MetadataNewArray < MetadataArray
+end
diff --git a/app/models/metadata_new_belongs_to.rb b/app/models/metadata_new_belongs_to.rb
new file mode 100644
index 00000000..46d25ce6
--- /dev/null
+++ b/app/models/metadata_new_belongs_to.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Nueva interfaz
+class MetadataNewBelongsTo < MetadataBelongsTo
+ include Metadata::UnusedValuesConcern
+end
diff --git a/app/models/metadata_new_has_and_belongs_to_many.rb b/app/models/metadata_new_has_and_belongs_to_many.rb
new file mode 100644
index 00000000..44102ec0
--- /dev/null
+++ b/app/models/metadata_new_has_and_belongs_to_many.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para relaciones muchos a muchos
+class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end
diff --git a/app/models/metadata_new_has_many.rb b/app/models/metadata_new_has_many.rb
new file mode 100644
index 00000000..e4b3869c
--- /dev/null
+++ b/app/models/metadata_new_has_many.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Interfaz nueva para uno a muchos
+class MetadataNewHasMany < MetadataHasMany
+ include Metadata::UnusedValuesConcern
+end
diff --git a/app/models/metadata_new_has_one.rb b/app/models/metadata_new_has_one.rb
new file mode 100644
index 00000000..b75b14ae
--- /dev/null
+++ b/app/models/metadata_new_has_one.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para relaciones 1:1
+class MetadataNewHasOne < MetadataHasOne; end
diff --git a/app/models/metadata_new_predefined_array.rb b/app/models/metadata_new_predefined_array.rb
new file mode 100644
index 00000000..8c15155f
--- /dev/null
+++ b/app/models/metadata_new_predefined_array.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+# Nueva interfaz para arrays predefinidos
+class MetadataNewPredefinedArray < MetadataPredefinedArray; end
diff --git a/app/models/metadata_new_predefined_value.rb b/app/models/metadata_new_predefined_value.rb
new file mode 100644
index 00000000..929934b2
--- /dev/null
+++ b/app/models/metadata_new_predefined_value.rb
@@ -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
diff --git a/app/models/metadata_predefined_array.rb b/app/models/metadata_predefined_array.rb
index b8e5050e..566a491b 100644
--- a/app/models/metadata_predefined_array.rb
+++ b/app/models/metadata_predefined_array.rb
@@ -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
diff --git a/app/models/metadata_predefined_value.rb b/app/models/metadata_predefined_value.rb
index 9cf36382..dbdb1e48 100644
--- a/app/models/metadata_predefined_value.rb
+++ b/app/models/metadata_predefined_value.rb
@@ -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.
diff --git a/app/models/metadata_related_posts.rb b/app/models/metadata_related_posts.rb
index 42d1381b..6ca09f7d 100644
--- a/app/models/metadata_related_posts.rb
+++ b/app/models/metadata_related_posts.rb
@@ -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
diff --git a/app/models/metadata_slug.rb b/app/models/metadata_slug.rb
index b0fe8cec..417cfa0b 100644
--- a/app/models/metadata_slug.rb
+++ b/app/models/metadata_slug.rb
@@ -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
diff --git a/app/models/metadata_string.rb b/app/models/metadata_string.rb
index c1d888b1..cb3ad264 100644
--- a/app/models/metadata_string.rb
+++ b/app/models/metadata_string.rb
@@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
true && !private?
end
+ def titleize?
+ true
+ end
+
private
# No se permite HTML en las strings
diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb
index c762220e..29391ac6 100644
--- a/app/models/metadata_template.rb
+++ b/app/models/metadata_template.rb
@@ -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
diff --git a/app/models/metadata_text.rb b/app/models/metadata_text.rb
index 103bcd0a..3ac336bb 100644
--- a/app/models/metadata_text.rb
+++ b/app/models/metadata_text.rb
@@ -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
diff --git a/app/models/metadata_title.rb b/app/models/metadata_title.rb
new file mode 100644
index 00000000..6d6b1bde
--- /dev/null
+++ b/app/models/metadata_title.rb
@@ -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
diff --git a/app/models/post.rb b/app/models/post.rb
index 327df3e2..a1758464 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -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.
diff --git a/app/services/post_service.rb b/app/services/post_service.rb
index 84f58dad..920c912e 100644
--- a/app/services/post_service.rb
+++ b/app/services/post_service.rb
@@ -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
diff --git a/app/views/bootstrap/_alert.haml b/app/views/bootstrap/_alert.haml
index 85bcbe84..a14064ce 100644
--- a/app/views/bootstrap/_alert.haml
+++ b/app/views/bootstrap/_alert.haml
@@ -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
diff --git a/app/views/bootstrap/_btn.haml b/app/views/bootstrap/_btn.haml
new file mode 100644
index 00000000..1bbebb26
--- /dev/null
+++ b/app/views/bootstrap/_btn.haml
@@ -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
diff --git a/app/views/bootstrap/_card.haml b/app/views/bootstrap/_card.haml
new file mode 100644
index 00000000..87e9691a
--- /dev/null
+++ b/app/views/bootstrap/_card.haml
@@ -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
diff --git a/app/views/bootstrap/_custom_checkbox.haml b/app/views/bootstrap/_custom_checkbox.haml
index 0c3ff3a6..dbef7516 100644
--- a/app/views/bootstrap/_custom_checkbox.haml
+++ b/app/views/bootstrap/_custom_checkbox.haml
@@ -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
diff --git a/app/views/bootstrap/_modal.haml b/app/views/bootstrap/_modal.haml
new file mode 100644
index 00000000..ab8d3db3
--- /dev/null
+++ b/app/views/bootstrap/_modal.haml
@@ -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] 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')
diff --git a/app/views/bootstrap/_responsive.haml b/app/views/bootstrap/_responsive.haml
new file mode 100644
index 00000000..1b51de97
--- /dev/null
+++ b/app/views/bootstrap/_responsive.haml
@@ -0,0 +1,4 @@
+- local_assigns[:ratio] ||= '1by1'
+
+.embed-responsive{ class: "embed-responsive-#{local_assigns[:ratio]}" }
+ .embed-responsive-item= yield
diff --git a/app/views/components/_dropdown.haml b/app/views/components/_dropdown.haml
index 6f34950b..923c603a 100644
--- a/app/views/components/_dropdown.haml
+++ b/app/views/components/_dropdown.haml
@@ -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
diff --git a/app/views/components/_dropdown_button.haml b/app/views/components/_dropdown_button.haml
index d6de6c8e..2ec51470 100644
--- a/app/views/components/_dropdown_button.haml
+++ b/app/views/components/_dropdown_button.haml
@@ -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
diff --git a/app/views/components/_dropdown_item.haml b/app/views/components/_dropdown_item.haml
index a4d363a8..a8d0f6f8 100644
--- a/app/views/components/_dropdown_item.haml
+++ b/app/views/components/_dropdown_item.haml
@@ -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' }
diff --git a/app/views/components/_select_all.haml b/app/views/components/_select_all.haml
index 9778cd13..ebbb5c66 100644
--- a/app/views/components/_select_all.haml
+++ b/app/views/components/_select_all.haml
@@ -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')
diff --git a/app/views/devise/shared/_links.haml b/app/views/devise/shared/_links.haml
index 5d5c0b67..f623ce8a 100644
--- a/app/views/devise/shared/_links.haml
+++ b/app/views/devise/shared/_links.haml
@@ -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),
diff --git a/app/views/moderation_queue/_account.haml b/app/views/moderation_queue/_account.haml
index 498d78f4..211a62f1 100644
--- a/app/views/moderation_queue/_account.haml
+++ b/app/views/moderation_queue/_account.haml
@@ -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
diff --git a/app/views/moderation_queue/_comment.haml b/app/views/moderation_queue/_comment.haml
index a80bd27c..88ea106b 100644
--- a/app/views/moderation_queue/_comment.haml
+++ b/app/views/moderation_queue/_comment.haml
@@ -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
diff --git a/app/views/moderation_queue/_instance.haml b/app/views/moderation_queue/_instance.haml
index c380089a..793de6ac 100644
--- a/app/views/moderation_queue/_instance.haml
+++ b/app/views/moderation_queue/_instance.haml
@@ -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
diff --git a/app/views/posts/_attributes.haml b/app/views/posts/_attributes.haml
index ed958d08..03887866 100644
--- a/app/views/posts/_attributes.haml
+++ b/app/views/posts/_attributes.haml
@@ -4,13 +4,14 @@
@param post [Post]
@param site [Site]
@param dir [String]
-- post.attributes.each do |attribute|
+ @param except [Array]
+- (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))
diff --git a/app/views/posts/_attributes_nested.haml b/app/views/posts/_attributes_nested.haml
index 83bcc51c..5036b9c7 100644
--- a/app/views/posts/_attributes_nested.haml
+++ b/app/views/posts/_attributes_nested.haml
@@ -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
diff --git a/app/views/posts/_errors.haml b/app/views/posts/_errors.haml
new file mode 100644
index 00000000..3b0a89dd
--- /dev/null
+++ b/app/views/posts/_errors.haml
@@ -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
diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml
index 92bee939..1c9c623e 100644
--- a/app/views/posts/_form.haml
+++ b/app/views/posts/_form.haml
@@ -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' } }
diff --git a/app/views/posts/_htmx_form.haml b/app/views/posts/_htmx_form.haml
new file mode 100644
index 00000000..afa1b44c
--- /dev/null
+++ b/app/views/posts/_htmx_form.haml
@@ -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)
diff --git a/app/views/posts/_new_array_value.haml b/app/views/posts/_new_array_value.haml
new file mode 100644
index 00000000..73bea5dc
--- /dev/null
+++ b/app/views/posts/_new_array_value.haml
@@ -0,0 +1 @@
+%li= value
diff --git a/app/views/posts/_new_has_one.haml b/app/views/posts/_new_has_one.haml
new file mode 100644
index 00000000..f5286670
--- /dev/null
+++ b/app/views/posts/_new_has_one.haml
@@ -0,0 +1,2 @@
+= render 'posts/new_related_post', post: post, modal_id: modal_id
+%input{ type: 'hidden', name: name, value: value }
diff --git a/app/views/posts/_new_related_post.haml b/app/views/posts/_new_related_post.haml
new file mode 100644
index 00000000..236abc42
--- /dev/null
+++ b/app/views/posts/_new_related_post.haml
@@ -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
diff --git a/app/views/posts/_required_checkbox.haml b/app/views/posts/_required_checkbox.haml
new file mode 100644
index 00000000..cf2fc0de
--- /dev/null
+++ b/app/views/posts/_required_checkbox.haml
@@ -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]
diff --git a/app/views/posts/_submit.haml b/app/views/posts/_submit.haml
index c6c0a68a..41d6f420 100644
--- a/app/views/posts/_submit.haml
+++ b/app/views/posts/_submit.haml
@@ -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 }
diff --git a/app/views/posts/_validation.haml b/app/views/posts/_validation.haml
new file mode 100644
index 00000000..c28a743a
--- /dev/null
+++ b/app/views/posts/_validation.haml
@@ -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
diff --git a/app/views/posts/attribute_ro/_new_array.haml b/app/views/posts/attribute_ro/_new_array.haml
new file mode 100644
index 00000000..20a0a545
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_array.haml
@@ -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
diff --git a/app/views/posts/attribute_ro/_new_belongs_to.haml b/app/views/posts/attribute_ro/_new_belongs_to.haml
new file mode 100644
index 00000000..c7e06be8
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_belongs_to.haml
@@ -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)
diff --git a/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml b/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml
new file mode 100644
index 00000000..d6b51a7a
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_and_belongs_to_many.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ %ul{ dir: dir, lang: locale }
+ - metadata.has_many.each do |p|
+ %li= link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_has_many.haml b/app/views/posts/attribute_ro/_new_has_many.haml
new file mode 100644
index 00000000..d6b51a7a
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_many.haml
@@ -0,0 +1,6 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ %ul{ dir: dir, lang: locale }
+ - metadata.has_many.each do |p|
+ %li= link_to p.title.value, site_post_path(site, p.id)
diff --git a/app/views/posts/attribute_ro/_new_has_one.haml b/app/views/posts/attribute_ro/_new_has_one.haml
new file mode 100644
index 00000000..425e659e
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_has_one.haml
@@ -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)
diff --git a/app/views/posts/attribute_ro/_new_predefined_array.haml b/app/views/posts/attribute_ro/_new_predefined_array.haml
new file mode 100644
index 00000000..88a82626
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_predefined_array.haml
@@ -0,0 +1,5 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td
+ - metadata.value.each do |v|
+ %span.badge.badge-primary{ dir: dir, lang: locale }= metadata.values.key v
diff --git a/app/views/posts/attribute_ro/_new_predefined_value.haml b/app/views/posts/attribute_ro/_new_predefined_value.haml
new file mode 100644
index 00000000..d44eef69
--- /dev/null
+++ b/app/views/posts/attribute_ro/_new_predefined_value.haml
@@ -0,0 +1,3 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }= metadata.to_s
diff --git a/app/views/posts/attribute_ro/_predefined_value.haml b/app/views/posts/attribute_ro/_predefined_value.haml
index 67642e2c..d44eef69 100644
--- a/app/views/posts/attribute_ro/_predefined_value.haml
+++ b/app/views/posts/attribute_ro/_predefined_value.haml
@@ -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
diff --git a/app/views/posts/attribute_ro/_title.haml b/app/views/posts/attribute_ro/_title.haml
new file mode 100644
index 00000000..67642e2c
--- /dev/null
+++ b/app/views/posts/attribute_ro/_title.haml
@@ -0,0 +1,3 @@
+%tr{ id: attribute }
+ %th= post_label_t(attribute, post: post)
+ %td{ dir: dir, lang: locale }= metadata.value
diff --git a/app/views/posts/attributes/_boolean.haml b/app/views/posts/attributes/_boolean.haml
index e07feca4..6928ebd7 100644
--- a/app/views/posts/attributes/_boolean.haml
+++ b/app/views/posts/attributes/_boolean.haml
@@ -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)}",
diff --git a/app/views/posts/attributes/_file.haml b/app/views/posts/attributes/_file.haml
index 20c27399..364e70fd 100644
--- a/app/views/posts/attributes/_file.haml
+++ b/app/views/posts/attributes/_file.haml
@@ -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',
diff --git a/app/views/posts/attributes/_geo.haml b/app/views/posts/attributes/_geo.haml
index dee4707e..cd21a6f3 100644
--- a/app/views/posts/attributes/_geo.haml
+++ b/app/views/posts/attributes/_geo.haml
@@ -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' }
diff --git a/app/views/posts/attributes/_image.haml b/app/views/posts/attributes/_image.haml
index 241a78e8..aa5f2038 100644
--- a/app/views/posts/attributes/_image.haml
+++ b/app/views/posts/attributes/_image.haml
@@ -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',
diff --git a/app/views/posts/attributes/_new_array.haml b/app/views/posts/attributes/_new_array.haml
new file mode 100644
index 00000000..1ed82e98
--- /dev/null
+++ b/app/views/posts/attributes/_new_array.haml
@@ -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 }
diff --git a/app/views/posts/attributes/_new_belongs_to.haml b/app/views/posts/attributes/_new_belongs_to.haml
new file mode 100644
index 00000000..661f3d27
--- /dev/null
+++ b/app/views/posts/attributes/_new_belongs_to.haml
@@ -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'
diff --git a/app/views/posts/attributes/_new_content.haml b/app/views/posts/attributes/_new_content.haml
index cbdf8f94..bc3d1cb1 100644
--- a/app/views/posts/attributes/_new_content.haml
+++ b/app/views/posts/attributes/_new_content.haml
@@ -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'
diff --git a/app/views/posts/attributes/_new_has_and_belongs_to_many.haml b/app/views/posts/attributes/_new_has_and_belongs_to_many.haml
new file mode 100644
index 00000000..6959ecf7
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_and_belongs_to_many.haml
@@ -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'
diff --git a/app/views/posts/attributes/_new_has_many.haml b/app/views/posts/attributes/_new_has_many.haml
new file mode 100644
index 00000000..c46bc84a
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_many.haml
@@ -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'
diff --git a/app/views/posts/attributes/_new_has_one.haml b/app/views/posts/attributes/_new_has_one.haml
new file mode 100644
index 00000000..573cd37e
--- /dev/null
+++ b/app/views/posts/attributes/_new_has_one.haml
@@ -0,0 +1,61 @@
+-#
+ 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}]"
+ target_id = random_id
+ 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
+ layout = metadata.filter[:layout]
+ invalid_id = random_id
+ submitting_id = random_id
+ saved_id = random_id
+
+%div{ data: { controller: 'modal' }}
+ .form-group
+ = hidden_field_tag name, ''
+ .d-flex.align-items-center.justify-content-between
+ = label_tag id, post_label_t(attribute, post: post), class: 'h3'
+ = render 'bootstrap/btn', content: t('.edit'), data: { action: 'modal#showAnother', 'modal-show-value': modal_id }, id: random_id
+
+ -# Aquí se reemplaza por la tarjeta y el UUID luego de guardar
+ .row.no-gutters.placeholder-glow{ id: target_id }
+ -# @todo issue-7537
+ - if !metadata.empty? && (indexed_post = site.indexed_posts.find_by(post_id: metadata.value))
+ = render 'posts/new_has_one', post: indexed_post, name: name, value: metadata.value, modal_id: modal_id
+
+-#
+ El modal se genera por fuera del formulario, para poder enviar los
+ datos y recibir su UUID en respuesta.
+- content_for :post_form do
+ %div{ id: modal_id, data: { controller: 'modal' }}
+ -# Si hay un solo layout o el post asociado ya existía
+ - if layout.is_a?(String) || metadata.has_one.present?
+ = render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{id}_body" do
+ -# @todo ocultar el modal después de guardar
+ .placeholder-glow{ 'hx-get': site_posts_form_path(site, layout: layout, base: id, name: name, form: form_id, swap: 'innerHTML', target: target_id, attribute: 'new_has_one', modal_id: modal_id, uuid: metadata.value, invalid: invalid_id, submitting: submitting_id, saved: saved_id, inverse: metadata.inverse, metadata.inverse => post.uuid.value), 'hx-trigger': 'load' }
+ %span.placeholder.w-100.h-100
+
+ - content_for :"#{id}_footer" do
+ = render 'posts/validation', site: site, invalid: { id: invalid_id }, submitting: { id: submitting_id }
+ = render 'bootstrap/alert', class: 'm-0 d-none fade', id: saved_id, data: { controller: 'notification', action: 'notification:show@window->notification#show', 'notification-hide-class': 'hide', 'notification-show-class': 'show' } do
+ = t('.saved')
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit', class: 'm-0 mt-1 mr-1'
+ = render 'bootstrap/btn', content: t('.close'), action: 'modal#hide', class: 'm-0 mt-1 mr-1'
+
+ - else
+ -# @todo Implementar selección de layout para cargar el formulario correcto
+ Nada
diff --git a/app/views/posts/attributes/_new_predefined_array.haml b/app/views/posts/attributes/_new_predefined_array.haml
new file mode 100644
index 00000000..0afa253d
--- /dev/null
+++ b/app/views/posts/attributes/_new_predefined_array.haml
@@ -0,0 +1,57 @@
+-#
+ Genera un listado de checkboxes entre los que se puede elegir para
+ guardar, pero no se pueden agregar nuevos.
+
+:ruby
+ id = id_for(base, attribute)
+ name = "#{base}[#{attribute}][]"
+ form_id = random_id
+ controllers = %w[modal array]
+ 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
+ = 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.
+ %ul.placeholder-glow{ data: { 'array-target': 'current' } }
+ - metadata.values.invert.slice(*metadata.value).each_value 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)
+ %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: "#{id}_body" }
+ -# Eliminamos las tildes para poder buscar independientemente de cómo se escriba
+ - metadata.values.each_pair do |value, key|
+ = render 'targets/array/item', class: 'mb-2', value: key, 'human-value': value do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: key, checked: metadata.value.include?(key), content: value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+
+ - content_for :"#{id}_footer" do
+ -# Alinear los botones a la derecha
+ .flex-grow-1
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
diff --git a/app/views/posts/attributes/_new_predefined_value.haml b/app/views/posts/attributes/_new_predefined_value.haml
new file mode 100644
index 00000000..20bd5d0b
--- /dev/null
+++ b/app/views/posts/attributes/_new_predefined_value.haml
@@ -0,0 +1,65 @@
+-#
+ 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 = 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_array_value_path(site) } }
+ %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?, 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.
+ %ul.list-unstyled.px-3.font-weight-bold.placeholder-glow{ data: { 'array-target': 'current' } }
+ - unless metadata.empty?
+ = render 'posts/new_array_value', value: metadata.to_s
+
+ = 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, key|
+ = render 'targets/array/item', value: value, class: 'mb-2' do
+ = render 'bootstrap/custom_checkbox', name: name, id: random_id, value: key, checked: (metadata.value == key), content: value, type: 'radio'
+
+ - content_for :"#{id}_footer" do
+ = render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
+ = render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
diff --git a/app/views/posts/attributes/_non_geo.haml b/app/views/posts/attributes/_non_geo.haml
index 3f6a75a6..37bdcb39 100644
--- a/app/views/posts/attributes/_non_geo.haml
+++ b/app/views/posts/attributes/_non_geo.haml
@@ -1,5 +1,5 @@
.row{ data: { controller: 'non-geo', site: site.url } }
- .d-none{ hidden: true, data: { target: 'non-geo.overlay' }}
+ .d-none{ hidden: true, data: { 'non-geo-target': 'overlay' }}
.col-12.mb-3
%p.mb-0= post_label_t(attribute, post: post)
%p= post_label_t(attribute, post: post)
@@ -12,7 +12,7 @@
= text_field(*field_name_for(base, attribute, :lat),
value: metadata.value['lat'],
**field_options(attribute, metadata),
- data: { target: 'non-geo.lat' })
+ data: { 'non-geo-target': 'lat' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lat], metadata: metadata
.col
@@ -22,8 +22,8 @@
= text_field(*field_name_for(base, attribute, :lng),
value: metadata.value['lng'],
**field_options(attribute, metadata),
- data: { target: 'non-geo.lng' })
+ data: { 'non-geo-target': 'lng' })
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :lng], metadata: metadata
.col-12.mb-3
- %div{ data: { target: 'non-geo.map' }, style: 'height: 250px' }
+ %div{ data: { 'non-geo-target': 'map' }, style: 'height: 250px' }
diff --git a/app/views/posts/attributes/_title.haml b/app/views/posts/attributes/_title.haml
new file mode 100644
index 00000000..e69de29b
diff --git a/app/views/posts/attributes/_uuid.haml b/app/views/posts/attributes/_uuid.haml
index 0aab9802..60d74cfc 100644
--- a/app/views/posts/attributes/_uuid.haml
+++ b/app/views/posts/attributes/_uuid.haml
@@ -1 +1 @@
--# nada
+= hidden_field_tag "#{base}[#{attribute}]", metadata.value
diff --git a/app/views/posts/form.haml b/app/views/posts/form.haml
new file mode 100644
index 00000000..bb63aaeb
--- /dev/null
+++ b/app/views/posts/form.haml
@@ -0,0 +1,7 @@
+-#
+ El formulario sin ninguna decoración, para incluir dentro de otros
+ elementos.
+
+ @param :site [Site]
+ @param :post [Post]
+= render 'posts/htmx_form', site: @site, post: @post, locale: @locale, dir: t("locales.#{@locale}.dir"), base: pluck_param(:base)
diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml
index 3de30aa3..5d180518 100644
--- a/app/views/posts/index.haml
+++ b/app/views/posts/index.haml
@@ -1,7 +1,7 @@
- reorder_allowed = policy(@site).reorder?
- if reorder_allowed
- reorder_controller = { controller: 'reorder' }
- - reorder_target = { target: 'reorder.row' }
+ - reorder_target = { 'reorder-target': 'row' }
- else
- reorder_target = reorder_controller = {}
@@ -85,7 +85,7 @@
= submit_tag t('posts.reorder.submit'), class: 'btn btn-secondary'
%button.btn.btn-secondary{ data: { action: 'reorder#unselect' } }
= t('posts.reorder.unselect')
- %span.badge{ data: { target: 'reorder.counter' } } 0
+ %span.badge{ data: { 'reorder-target': 'counter' } } 0
%button.btn.btn-secondary{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
%button.btn.btn-secondary{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
%button.btn.btn-secondary{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
@@ -99,6 +99,8 @@
- dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size
- @posts.each_with_index do |post, i|
+ -# @todo issue-7537
+ - next if @site.layouts[post.layout].hidden?
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
diff --git a/app/views/posts/modal.haml b/app/views/posts/modal.haml
new file mode 100644
index 00000000..93d7e6a5
--- /dev/null
+++ b/app/views/posts/modal.haml
@@ -0,0 +1,71 @@
+-#
+
+ Genera un modal completo con el formulario del post y sus botones de
+ guardado.
+
+ Se comporta como "HTMX".
+
+
+:ruby
+ post = @post
+ site = post.site
+ locale = @locale
+ base = random_id
+ dir = t("locales.#{locale}.dir")
+ modal_id = pluck_param(:modal_id)
+ result_id = pluck_param(:result_id)
+ form_id = random_id
+ except = %i[date]
+
+ if (inverse = pluck_param(:inverse, optional: true))
+ except << inverse.to_sym
+ end
+
+ options = {
+ id: form_id,
+ multipart: true,
+ class: 'form post'
+ }
+
+ 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
+
+ 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'
+
+ options[:data] = data
+
+%div{ id: modal_id, data: { controller: 'modal' }}
+ = render 'bootstrap/modal', id: modal_id, modal_content_attributes: { class: 'h-100' } do
+ - content_for :"#{modal_id}_body" do
+ = form_tag url, **options do
+ = hidden_field_tag 'base', base
+ = hidden_field_tag 'result_id', result_id
+ = hidden_field_tag 'modal_id', modal_id
+ = hidden_field_tag "#{base}[layout]", post.layout.name
+ = hidden_field_tag 'hide', pluck_param((post.errors.empty? ? :show : :hide), optional: true) || pluck_param(:modal_id, optional: true)
+ = hidden_field_tag 'show', pluck_param((post.errors.empty? ? :hide : :show), optional: true)
+ - if inverse
+ = hidden_field_tag 'inverse', inverse
+
+ = render 'errors', post: post
+ = render 'posts/attributes', site: site, post: post, dir: dir, base: base, locale: locale, except: except
+ -# @todo Volver obligatorios?
+ - except.each do |attr|
+ - if (value = pluck_param(attr, optional: true)).present?
+ = hidden_field_tag "#{base}[#{attr}]", value
+
+ - content_for :"#{modal_id}_footer" do
+ -# = render 'posts/validation', site: site, invalid: { id: invalid_id }, submitting: { id: submitting_id }
+ -# = render 'bootstrap/alert', class: 'm-0 d-none fade', id: saved_id, data: { controller: 'notification', action: 'notification:show@window->notification#show', 'notification-hide-class': 'hide', 'notification-show-class': 'show' } do
+ = t('.saved')
+ = render 'bootstrap/btn', form: form_id, content: t('.save'), type: 'submit', class: 'm-0 mt-1 mr-1'
+ = render 'bootstrap/btn', content: t('.close'), action: 'modal#hide', class: 'm-0 mt-1 mr-1'
+ = yield(:post_form)
diff --git a/app/views/posts/new_array.haml b/app/views/posts/new_array.haml
new file mode 100644
index 00000000..7fd52a53
--- /dev/null
+++ b/app/views/posts/new_array.haml
@@ -0,0 +1,8 @@
+- item_id = random_id
+
+= render 'targets/array/item', value: @value, class: 'mb-2', id: item_id do
+ .d-flex.flex-row.flex-wrap
+ .flex-grow-1
+ = render 'bootstrap/custom_checkbox', name: @name, id: random_id, value: @value, checked: true, content: @value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
+ %div
+ %button.btn.btn-sm.m-0{ data: { action: 'array#remove', 'remove-target-param': item_id } }= t('.remove')
diff --git a/app/views/posts/new_array_value.haml b/app/views/posts/new_array_value.haml
new file mode 100644
index 00000000..c71ed3b5
--- /dev/null
+++ b/app/views/posts/new_array_value.haml
@@ -0,0 +1 @@
+= render 'posts/new_array_value', value: @value
diff --git a/app/views/posts/new_belongs_to_value.haml b/app/views/posts/new_belongs_to_value.haml
new file mode 100644
index 00000000..c7917b7d
--- /dev/null
+++ b/app/views/posts/new_belongs_to_value.haml
@@ -0,0 +1,2 @@
+= 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: true, content: @value, type: 'radio'
diff --git a/app/views/posts/new_has_many_value.haml b/app/views/posts/new_has_many_value.haml
new file mode 100644
index 00000000..7204a268
--- /dev/null
+++ b/app/views/posts/new_has_many_value.haml
@@ -0,0 +1,2 @@
+= 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: true, content: @value, data: { action: 'required-checkbox#change', 'required-checkbox-target': 'checkbox' }
diff --git a/app/views/posts/new_has_one.haml b/app/views/posts/new_has_one.haml
new file mode 100644
index 00000000..8f65ff08
--- /dev/null
+++ b/app/views/posts/new_has_one.haml
@@ -0,0 +1 @@
+= render 'posts/new_has_one', post: @indexed_post, name: pluck_param(:name), value: @uuid
diff --git a/app/views/posts/new_has_one_value.haml b/app/views/posts/new_has_one_value.haml
new file mode 100644
index 00000000..9ce50526
--- /dev/null
+++ b/app/views/posts/new_has_one_value.haml
@@ -0,0 +1 @@
+= render 'posts/new_has_one', post: @post.to_index, name: pluck_param(:name), value: @uuid, modal_id: pluck_param(:modal_id)
diff --git a/app/views/posts/new_related_post.haml b/app/views/posts/new_related_post.haml
new file mode 100644
index 00000000..1b173fa9
--- /dev/null
+++ b/app/views/posts/new_related_post.haml
@@ -0,0 +1 @@
+= render 'posts/new_related_post', post: @indexed_post, modal_id: pluck_param(:modal_id, optional: true), inverse: pluck_param(:inverse, optional: true)
diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml
index f44e11ed..12db05a4 100644
--- a/app/views/posts/show.haml
+++ b/app/views/posts/show.haml
@@ -4,7 +4,7 @@
.col-md-8
%article.content.table-responsive-md
= link_to t('posts.edit_post'),
- edit_site_post_path(@site, @post.id),
- class: 'btn btn-secondary btn-block'
+ edit_site_post_path(@site, @post.id),
+ class: 'btn btn-secondary btn-block'
= render 'table', dir: dir, site: @site, locale: @locale, post: @post, title: t('.front_matter')
diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml
index ec2712bf..258f28d3 100644
--- a/app/views/sites/_form.haml
+++ b/app/views/sites/_form.haml
@@ -64,7 +64,7 @@
disabled: design.disabled,
required: true, class: 'custom-control-input'
= f.label "design_id_#{design.id}", design.name,
- class: 'custom-control-label'
+ class: 'custom-control-label font-weight-bold'
.flex-fill
= sanitize_markdown design.description,
tags: %w[p a strong em]
@@ -93,7 +93,7 @@
= f.radio_button :licencia_id, licencia.id,
checked: licencia.id == site.licencia_id,
required: true, class: 'custom-control-input'
- = f.label "licencia_id_#{licencia.id}", class: 'custom-control-label' do
+ = f.label "licencia_id_#{licencia.id}", class: 'custom-control-label font-weight-bold' do
= licencia.name
= sanitize_markdown licencia.description,
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
diff --git a/app/views/sites/build.haml b/app/views/sites/build.haml
index c2becec0..2743c265 100644
--- a/app/views/sites/build.haml
+++ b/app/views/sites/build.haml
@@ -1 +1 @@
-= render 'sites/build', site: @site, class: params.permit(:class)[:class]
+= render 'sites/build', site: @site, class: pluck_param(:class, optional: true)
diff --git a/app/views/targets/array/_item.haml b/app/views/targets/array/_item.haml
new file mode 100644
index 00000000..a4fa80ac
--- /dev/null
+++ b/app/views/targets/array/_item.haml
@@ -0,0 +1,24 @@
+-#
+ Un item de un array.
+
+ Además de los valores por defecto, se pueden pasar otros atributos
+ para el div del ítem.
+
+ @param :value [String] El valor (requerido)
+ @param :human-value [String] El valor legible por humanes (opcional)
+ @param :send-value [String] El valor que se envía al controlador (opcional)
+ @param :searchable-value [String] El valor para usar en el filtro (opcional)
+
+:ruby
+ local_assigns[:'human-value'] ||= value
+ local_assigns[:'send-value'] ||= local_assigns[:'human-value']
+ local_assigns[:'searchable-value'] ||= local_assigns[:'human-value'].remove_diacritics.downcase
+ local_assigns.delete(:value)
+
+ data = local_assigns.delete(:data)
+ data ||= {}
+ data[:'human-value'] = local_assigns.delete(:'human-value')
+ data[:'send-value'] = local_assigns.delete(:'send-value')
+ data[:'searchable-value'] = local_assigns.delete(:'searchable-value')
+
+%div{ **local_assigns, data: { 'array-target': 'item', value: value, **data } }= yield
diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb
index 7d1eab9e..024a26ab 100644
--- a/config/initializers/core_extensions.rb
+++ b/config/initializers/core_extensions.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
String.include CoreExtensions::String::StripTags
+String.include CoreExtensions::String::RemoveDiacritics
Jekyll::Document.include CoreExtensions::Jekyll::Document::Path
Jekyll::DataReader.include Jekyll::Readers::DataReaderDecorator
@@ -125,7 +126,8 @@ module Jekyll
unless spec
I18n.with_locale(locale) do
- raise Jekyll::Errors::InvalidThemeName, I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name)
+ raise Jekyll::Errors::InvalidThemeName,
+ I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name)
rescue Jekyll::Errors::InvalidThemeName => e
ExceptionNotifier.notify_exception(e, data: { theme: name, site: File.basename(site.source) })
raise
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5e9a2377..c348d73b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -719,10 +719,13 @@ en:
submit:
save: 'Save'
save_draft: 'Save as draft'
- invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.'
- sending_help: Saving, please wait...
+ validation:
+ invalid: "Some fields need attention! Please search for the fields marked as not valid."
+ submitting: "Saving changes, please wait..."
attributes:
add: Add
+ title:
+ label: "Title"
lang:
label: Language
date:
@@ -752,6 +755,24 @@ en:
empty: "(Empty)"
draft:
label: Draft
+ new_has_many:
+ edit: "Edit"
+ disabled: "It's already associated to an article."
+ new_has_and_belongs_to_many:
+ edit: "Edit"
+ new_predefined_array:
+ edit: "Edit"
+ new_predefined_value:
+ empty: "(Empty)"
+ new_array:
+ edit: "Edit"
+ new_has_one:
+ edit: "Edit"
+ new_belongs_to:
+ edit: "Edit"
+ disabled: "It's already associated to an article."
+ required_checkbox:
+ required: "Please select at least one option."
reorder:
submit: 'Save order'
select: 'Select this post'
@@ -798,6 +819,7 @@ en:
destroy: Delete
confirm_destroy: Are you sure?
form:
+ confirm: "You have unsaved changes and changing pages may lose them, continue anyway?"
errors:
title: There are some errors on the form
help: Please, verify that all values are correct.
@@ -934,3 +956,21 @@ en:
title: "Publications"
indexed_posts:
deleted: "Deleted indexed post %{path} from %{site} (records: %{records})"
+ bootstrap:
+ modal:
+ accept: "Accept"
+ add: "Add %{layout}"
+ add_new: "Add new option"
+ cancel: "Cancel"
+ close: "Close without saving"
+ edit: "Edit"
+ filter: "Start typing to filter..."
+ save: "Save"
+ card:
+ edit: "Edit"
+ alert:
+ saved: "Changes were saved!"
+ targets:
+ array:
+ item:
+ remove: "Remove"
diff --git a/config/locales/es.yml b/config/locales/es.yml
index a07b3799..e9a52019 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -727,10 +727,13 @@ es:
submit:
save: 'Guardar'
save_draft: 'Guardar como borrador'
- invalid_help: '¡Te faltan completar algunos campos! Busca los que estén marcados como inválidos'
- sending_help: Guardando, por favor espera...
+ validation:
+ invalid: "¡Te falta completar algunos campos! Busca los que estén marcados como no válidos."
+ submitting: "Guardando, por favor espera..."
attributes:
add: Agregar
+ title:
+ label: "Título"
lang:
label: Idioma
date:
@@ -760,6 +763,26 @@ es:
empty: "(Vacío)"
draft:
label: Borrador
+ new_has_many:
+ edit: "Editar"
+ disabled: "Ya está relacionado con otro artículo."
+ new_has_and_belongs_to_many:
+ edit: "Editar"
+ new_predefined_array:
+ empty: "(Vacío)"
+ edit: "Editar"
+ new_predefined_value:
+ empty: "(Vacío)"
+ edit: "Editar"
+ new_array:
+ edit: "Editar"
+ new_has_one:
+ edit: "Editar"
+ new_belongs_to:
+ edit: "Editar"
+ disabled: "Ya está relacionado con otro artículo."
+ required_checkbox:
+ required: "Seleccioná al menos una opción."
reorder:
submit: 'Guardar orden'
select: 'Seleccionar este artículo'
@@ -806,6 +829,7 @@ es:
destroy: Borrar
confirm_destroy: ¿Estás segure?
form:
+ confirm: "Tenés cambios sin guardar y cambiar de página podría perderlos, ¿querés continuar de todas formas?"
errors:
title: Hay errores en el formulario
help: Por favor, verifica que todos los valores sean correctos.
@@ -942,3 +966,21 @@ es:
title: "Publicaciones"
indexed_posts:
deleted: "Eliminado artículo %{path} de %{site} (filas: %{records})"
+ bootstrap:
+ modal:
+ accept: "Aceptar"
+ add: "Agregar %{layout}"
+ add_new: "Agregar nueva opción"
+ cancel: "Cancelar"
+ close: "Cerrar sin guardar"
+ edit: "Editar"
+ filter: "Empezá a escribir para filtrar..."
+ save: "Guardar"
+ card:
+ edit: "Editar"
+ alert:
+ saved: "¡Cambios guardados!"
+ targets:
+ array:
+ item:
+ remove: "Eliminar"
diff --git a/config/routes.rb b/config/routes.rb
index 9d5c974a..110641e2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -99,6 +99,14 @@ Rails.application.routes.draw do
nested do
scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do
post :'posts/reorder', to: 'posts#reorder'
+
+ get :'posts/new_array', to: 'posts#new_array'
+ get :'posts/new_array_value', to: 'posts#new_array_value'
+ get :'posts/new_related_post', to: 'posts#new_related_post'
+ get :'posts/new_has_one', to: 'posts#new_has_one'
+ get :'posts/form', to: 'posts#form'
+ get :'posts/modal', to: 'posts#modal'
+
resources :posts do
get 'p/:page', action: :index, on: :collection
get :preview, to: 'posts#preview'
diff --git a/db/migrate/20240227134845_create_fediblocks.rb b/db/migrate/20240227134845_create_fediblocks.rb
index 03f65f7c..1a61ccba 100644
--- a/db/migrate/20240227134845_create_fediblocks.rb
+++ b/db/migrate/20240227134845_create_fediblocks.rb
@@ -12,7 +12,7 @@ class CreateFediblocks < ActiveRecord::Migration[6.1]
t.string :url, null: false
t.string :download_url, null: false
t.string :format, null: false
- t.jsonb :instances, default: []
+ t.jsonb :hostnames, default: []
end
YAML.safe_load(File.read('db/seeds/activity_pub/fediblocks.yml')).each do |fediblock|
diff --git a/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb b/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb
deleted file mode 100644
index bad343f2..00000000
--- a/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-# Cambia el nombre de la columna para que podamos obtener todas las
-# instancias de un fediblock
-class RenameFediblockInstancesToHostnames < ActiveRecord::Migration[6.1]
- def change
- rename_column :activity_pub_fediblocks, :instances, :hostnames
- end
-end
diff --git a/db/seeds/activity_pub/fediblocks.yml b/db/seeds/activity_pub/fediblocks.yml
index c977f9bf..35fe38cd 100644
--- a/db/seeds/activity_pub/fediblocks.yml
+++ b/db/seeds/activity_pub/fediblocks.yml
@@ -6,7 +6,7 @@
id: "9046789a-5de8-4b16-beed-796060f8f3cc"
- title: "Oliphant Tier 0"
url: "https://writer.oliphant.social/oliphant/the-oliphant-social-blocklist"
- download_url: "https://codeberg.org/oliphant/blocklists/raw/branch/main/blocklists/mastodon/tier0.csv"
+ download_url: "https://codeberg.org/oliphant/blocklists/raw/branch/main/blocklists/mastodon/seirdy-tier0.csv"
format: "mastodon"
id: "fc1efcb8-7e68-4a76-ae9e-0c447752b12b"
- title: "The Bad Space (90%)"
diff --git a/package.json b/package.json
index 088316bc..cd2ddadf 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,14 @@
"@babel/plugin-transform-runtime": "^7.12.17",
"@babel/preset-env": "^7.12.17",
"@babel/preset-typescript": "~7.12",
+ "@hotwired/stimulus": "^3.2.2",
+ "@hotwired/stimulus-webpack-helpers": "^1.0.1",
"@rails/actiontext": "^6.0.0",
"@rails/activestorage": "^6.1.3-1",
"@rails/ujs": "^6.1.3-1",
"@rails/webpacker": "5.4.4",
"@suttyweb/editor": "^0.1.29",
+ "@suttyweb/htmx.org": "2.0.0",
"babel-loader": "^8.2.2",
"bs-custom-file-input": "^1.3.4",
"chart.js": "^3.5.1",
@@ -22,7 +25,6 @@
"commonmark": "^0.29.0",
"fork-awesome": "^1.1.7",
"fork-ts-checker-webpack-plugin": "^6.1.0",
- "htmx.org": "^1.9.11",
"input-map": "git+https://0xacab.org/sutty/input-map.git",
"input-tag": "git+https://0xacab.org/sutty/input-tag.git",
"leaflet": "^1.7.1",
diff --git a/yarn.lock b/yarn.lock
index fc6ae7cb..0dece94d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1841,6 +1841,16 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+"@hotwired/stimulus-webpack-helpers@^1.0.1":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd"
+ integrity sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ==
+
+"@hotwired/stimulus@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608"
+ integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==
+
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
@@ -1984,6 +1994,11 @@
linkifyjs "^4.1.1"
prosemirror-svelte-nodeview "^1.0.2"
+"@suttyweb/htmx.org@2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@suttyweb/htmx.org/-/htmx.org-2.0.0.tgz#44435d834d143ae9b60daa454f68f8e72e6ebd7f"
+ integrity sha512-EJk9s8judGLIZ6c9N779z91WHPIfAkwkVY5QF7WH2ZT2Kt03k/hAoy7P4NjYreFIQcIo8d+TU/CIhViCmB4c0Q==
+
"@types/caseless@*":
version "0.12.2"
resolved "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz"
@@ -4575,11 +4590,6 @@ html-entities@^1.3.1:
resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz"
integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==
-htmx.org@^1.9.11:
- version "1.9.11"
- resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.9.11.tgz#00192041ee682d6ca7146d0fbd901169ffe72d87"
- integrity sha512-WlVuICn8dfNOOgYmdYzYG8zSnP3++AdHkMHooQAzGZObWpVXYathpz/I37ycF4zikR6YduzfCvEcxk20JkIUsw==
-
http-deceiver@^1.2.7:
version "1.2.7"
resolved "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz"