From ae06e3e788a8c5f92ccd735bc81baa1db7e309e9 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 17 May 2024 15:24:08 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20redise=C3=B1ar=20arrays=20#15068?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/posts_controller.rb | 17 +++ .../controllers/array_controller.js | 120 ++++++++++++++++++ app/javascript/etc/htmx_abort.js | 4 + app/models/metadata_new_array.rb | 6 + app/views/layouts/application.html.haml | 1 + app/views/posts/_new_array_value.haml | 2 + app/views/posts/attributes/_new_array.haml | 58 +++++++++ app/views/posts/new_array.haml | 8 ++ app/views/posts/new_array_value.haml | 1 + config/locales/en.yml | 9 ++ config/locales/es.yml | 9 ++ config/routes.rb | 4 + 12 files changed, 239 insertions(+) create mode 100644 app/javascript/controllers/array_controller.js create mode 100644 app/models/metadata_new_array.rb create mode 100644 app/views/posts/_new_array_value.haml create mode 100644 app/views/posts/attributes/_new_array.haml create mode 100644 app/views/posts/new_array.haml create mode 100644 app/views/posts/new_array_value.haml diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 057c3068..f241dfb1 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -15,6 +15,23 @@ class PostsController < ApplicationController { locale: locale } 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 authorize Post diff --git a/app/javascript/controllers/array_controller.js b/app/javascript/controllers/array_controller.js new file mode 100644 index 00000000..66cc2290 --- /dev/null +++ b/app/javascript/controllers/array_controller.js @@ -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, + ); + } +} 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/models/metadata_new_array.rb b/app/models/metadata_new_array.rb new file mode 100644 index 00000000..a76ff9f2 --- /dev/null +++ b/app/models/metadata_new_array.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Implementa la nueva interfaz de +class MetadataNewArray < MetadataArray + +end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index eaa15eb4..65d3f777 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -25,6 +25,7 @@ = render 'layouts/breadcrumb' = render 'layouts/flash' + = yield(:post_form) = yield - if flash[:js] diff --git a/app/views/posts/_new_array_value.haml b/app/views/posts/_new_array_value.haml new file mode 100644 index 00000000..75c5bf4d --- /dev/null +++ b/app/views/posts/_new_array_value.haml @@ -0,0 +1,2 @@ +.col + %p= value diff --git a/app/views/posts/attributes/_new_array.haml b/app/views/posts/attributes/_new_array.haml new file mode 100644 index 00000000..79ba22e2 --- /dev/null +++ b/app/views/posts/attributes/_new_array.haml @@ -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 } diff --git a/app/views/posts/new_array.haml b/app/views/posts/new_array.haml new file mode 100644 index 00000000..81cdcdea --- /dev/null +++ b/app/views/posts/new_array.haml @@ -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') 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/config/locales/en.yml b/config/locales/en.yml index 5e9a2377..88b1db19 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -721,7 +721,16 @@ en: save_draft: 'Save as draft' invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.' sending_help: Saving, please wait... + new_array: + remove: "Remove" attributes: + new_array: + edit: "Edit" + filter: "Start typing to filter..." + add_new: "Add new option" + add: "Add" + accept: "Accept" + cancel: "Cancel" add: Add lang: label: Language diff --git a/config/locales/es.yml b/config/locales/es.yml index a07b3799..4945c19f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -729,7 +729,16 @@ es: 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... + new_array: + remove: "Eliminar" 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 lang: label: Idioma diff --git a/config/routes.rb b/config/routes.rb index 9d5c974a..eb20edce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,10 @@ 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' + resources :posts do get 'p/:page', action: :index, on: :collection get :preview, to: 'posts#preview'