5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-23 00:06:21 +00:00

feat: rediseñar arrays #15068

This commit is contained in:
f 2024-05-17 15:24:08 -03:00
parent 76de2e543e
commit ae06e3e788
No known key found for this signature in database
12 changed files with 239 additions and 0 deletions

View file

@ -15,6 +15,23 @@ class PostsController < ApplicationController
{ locale: locale } { locale: locale }
end end
# @todo Mover a tu propio scope
def new_array
@value = params.require(:value).strip
@name = params.require(:name).strip
id = params.require(:id).strip
headers['HX-Trigger-After-Swap'] = 'htmx:resetForm'
render layout: false
end
def new_array_value
@value = params.require(:value).strip
render layout: false
end
def index def index
authorize Post authorize Post

View file

@ -0,0 +1,120 @@
import { Controller } from "stimulus";
export default class extends Controller {
static targets = ["item", "search", "current"];
connect() {
// TODO: Stimulus >1
this.newArrayValueURL = new URL(window.location.origin);
this.newArrayValueURL.pathname = this.element.dataset.arrayNewArrayValue;
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;
}
/*
* Al cancelar, se vuelve al estado original de la lista
*/
cancel(event) {
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 = [];
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.value);
// TODO: Renderizarlas todas juntas
fetch(this.newArrayValueURL)
.then((response) => response.text())
.then((body) =>
this.currentTarget.insertAdjacentHTML("beforeend", body),
);
}
// TODO: Stimulus >1
this.element.dataset.arrayOriginalValue = JSON.stringify(
this.originalValue,
);
}
}

View file

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

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Implementa la nueva interfaz de
class MetadataNewArray < MetadataArray
end

View file

@ -25,6 +25,7 @@
= render 'layouts/breadcrumb' = render 'layouts/breadcrumb'
= render 'layouts/flash' = render 'layouts/flash'
= yield(:post_form)
= yield = yield
- if flash[:js] - if flash[:js]

View file

@ -0,0 +1,2 @@
.col
%p= value

View file

@ -0,0 +1,58 @@
-#
Genera un listado de checkboxes entre los que se puede elegir para guardar
- id = "#{base}_#{attribute}"
- name = "#{base}[#{attribute}][]"
- form_id = "form-#{Nanoid.generate}"
%div{ data: { controller: 'modal array', 'array-original-value': metadata.value.to_json, 'array-new-array-value': site_posts_new_array_value_path(site) } }
.form-group
= hidden_field_tag name, ''
= label_tag id, post_label_t(attribute, post: post)
-# Mostramos la lista de valores actuales.
Al aceptar el modal, se vacía el listado y se completa en base a
renderizaciones con HTMX. Para poder hacer eso, tenemos que poder
acceder a todos los items dentro del modal (como array.item) y
enviar el valor al endpoint que devuelve uno por uno. Esto lo
tenemos disponible en Stimulus, pero queremos usar HTMX o técnica
similar para poder renderizar del lado del servidor.
Para poder cancelar, mantenemos el estado original y desactivamos
o activamos los ítemes según estén incluidos en esa lista o no.
.row.row-cols-1.row-cols-md-2{ data: { target: 'array.current' } }
- metadata.value.sort_by(&:remove_diacritics).each do |value|
= render 'posts/new_array_value', value: value
= render 'bootstrap/btn', content: t('.edit'), action: 'modal#show'
= render 'bootstrap/modal', id: id, modal_content_attributes: { class: 'h-100' }, hide_actions: ['array#cancel'] do
- content_for :"#{id}_header" do
.form-group.flex-grow-1.mb-0
= label_tag id, post_label_t(attribute, post: post)
%input.form-control{data: { target: 'array.search', action: 'input->array#search' }, type: 'search', placeholder: t('.filter') }
- content_for :"#{id}_body" do
.form-group.mb-0{ id: "#{id}_body" }
-# Eliminamos las tildes para poder buscar independientemente de cómo se escriba
- metadata.values.sort_by(&:remove_diacritics).each do |value|
.mb-2{ data: { target: 'array.item', 'searchable-value': value.remove_diacritics.downcase, value: value } }
= render 'bootstrap/custom_checkbox', name: name, id: "value-#{Nanoid.generate}", value: value, checked: metadata.value.include?(value), content: value
- 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')}
.input-group-append
= render 'bootstrap/btn', content: t('.add'), form: form_id, type: 'submit', class: 'mb-0 mr-0'
= render 'bootstrap/btn', content: t('.accept'), action: 'array#accept modal#hide', class: 'm-0 mr-1'
= render 'bootstrap/btn', content: t('.cancel'), action: 'array#cancel modal#hide', class: 'm-0'
-# Los formularios para HTMX se colocan por fuera del formulario
principal, porque HTML5 no soporta formularios anidados. Los campos
quedan unidos al formulario por su atributo `id`.
Al enviar el formulario se obtiene una nueva opción con el valor
y se la agrega al final del listado.
- content_for :post_form do
%form{ id: form_id, 'hx-get': site_posts_new_array_path(site), 'hx-target': "##{id}_body", 'hx-swap': 'beforeend' }
%input{ type: 'hidden', name: 'name', value: name }
%input{ type: 'hidden', name: 'id', value: form_id }

View file

@ -0,0 +1,8 @@
- item_id = "item-#{Nanoid.generate}"
.mb-2{ id: item_id, data: { target: 'array.item', 'searchable-value': @value.remove_diacritics.downcase, value: @value } }
.d-flex.flex-row.flex-wrap
.flex-grow-1
= render 'bootstrap/custom_checkbox', name: @name, id: "value-#{Nanoid.generate}", value: @value, checked: true, content: @value
%div
%button.btn.btn-sm.m-0{ data: { action: 'array#remove', 'remove-target-param': item_id }}= t('.remove')

View file

@ -0,0 +1 @@
= render 'posts/new_array_value', value: @value

View file

@ -721,7 +721,16 @@ en:
save_draft: 'Save as draft' save_draft: 'Save as draft'
invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.' invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.'
sending_help: Saving, please wait... sending_help: Saving, please wait...
new_array:
remove: "Remove"
attributes: attributes:
new_array:
edit: "Edit"
filter: "Start typing to filter..."
add_new: "Add new option"
add: "Add"
accept: "Accept"
cancel: "Cancel"
add: Add add: Add
lang: lang:
label: Language label: Language

View file

@ -729,7 +729,16 @@ es:
save_draft: 'Guardar como borrador' save_draft: 'Guardar como borrador'
invalid_help: '¡Te faltan completar algunos campos! Busca los que estén marcados como inválidos' invalid_help: '¡Te faltan completar algunos campos! Busca los que estén marcados como inválidos'
sending_help: Guardando, por favor espera... sending_help: Guardando, por favor espera...
new_array:
remove: "Eliminar"
attributes: attributes:
new_array:
edit: "Editar"
filter: "Empezá a escribir para filtrar..."
add_new: "Agregar nueva opción"
add: "Agregar"
accept: "Aceptar"
cancel: "Cancelar"
add: Agregar add: Agregar
lang: lang:
label: Idioma label: Idioma

View file

@ -99,6 +99,10 @@ Rails.application.routes.draw do
nested do nested do
scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do
post :'posts/reorder', to: 'posts#reorder' post :'posts/reorder', to: 'posts#reorder'
get :'posts/new_array', to: 'posts#new_array'
get :'posts/new_array_value', to: 'posts#new_array_value'
resources :posts do resources :posts do
get 'p/:page', action: :index, on: :collection get 'p/:page', action: :index, on: :collection
get :preview, to: 'posts#preview' get :preview, to: 'posts#preview'