diff --git a/Gemfile b/Gemfile index 66baec53..022275b8 100644 --- a/Gemfile +++ b/Gemfile @@ -86,6 +86,7 @@ gem 'rubanok' gem 'after_commit_everywhere', '~> 1.0' gem 'aasm' gem 'que-web' +gem 'nanoid' # database gem 'hairtrigger' diff --git a/Gemfile.lock b/Gemfile.lock index 97721a98..556f16e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -377,6 +377,7 @@ GEM multi_xml (0.6.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) + nanoid (2.0.0) net-imap (0.4.9) date net-protocol @@ -682,6 +683,7 @@ DEPENDENCIES memory_profiler mini_magick mobility + nanoid net-ssh nokogiri pg diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e4c4d343..b0db7459 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -22,6 +22,7 @@ $form-feedback-icon-valid-color: $black; $component-active-bg: $magenta; $btn-white-space: nowrap; $font-weight-bolder: 700; +$zindex-modal-backdrop: 0; $spacers: ( 2-plus: 0.75rem @@ -328,13 +329,13 @@ svg { } } -.custom-control-label { - font-weight: bold; -} - .designs { .design { margin-top: 1rem; + + .custom-control-label { + font-weight: bold; + } } } 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/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 00000000..3a614b80 --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -0,0 +1,40 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static targets = ["modal", "backdrop"]; + + show(event = undefined) { + event?.preventDefault(); + + 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"); + + setTimeout(() => { + this.modalTarget.classList.add("show"); + this.backdropTarget.classList.add("show"); + }, 1); + } + + hide(event = undefined) { + event?.preventDefault(); + + 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"); + + setTimeout(() => { + this.modalTarget.style.display = ""; + this.backdropTarget.style.display = ""; + }, 500); + + window.document.body.classList.remove("modal-open"); + } +} 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/packs/application.js b/app/javascript/packs/application.js index e10e2b5d..c523b0f0 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -42,3 +42,4 @@ Turbolinks.start() ActiveStorage.start() window.htmx = require('htmx.org/dist/htmx.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/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/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/_custom_checkbox.haml b/app/views/bootstrap/_custom_checkbox.haml index 0c3ff3a6..c8cd1b41 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) + checkbox_attributes[:type] ||= 'checkbox' .custom-control.custom-checkbox - %input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required } + %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..f6dafc2a --- /dev/null +++ b/app/views/bootstrap/_modal.haml @@ -0,0 +1,39 @@ +-# + # 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[:modal_content_attributes] ||= {} + +.modal.fade{ tabindex: -1, aria: { hidden: 'true' }, data: { target: 'modal.modal' } } + .modal-backdrop.fade{ data: { target: 'modal.backdrop', action: local_assigns[:hide_actions].join(' ') } } + .modal-dialog.modal-dialog-scrollable.modal-dialog-centered + .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/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/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/attributes/_new_array.haml b/app/views/posts/attributes/_new_array.haml new file mode 100644 index 00000000..394345a6 --- /dev/null +++ b/app/views/posts/attributes/_new_array.haml @@ -0,0 +1,59 @@ +-# + Genera un listado de checkboxes entre los que se puede elegir para guardar +:ruby + 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..62f74854 --- /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/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index 7d1eab9e..6861da45 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 8fa82473..a7609a40 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -717,7 +717,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 056491ac..1474c935 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -725,7 +725,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'