diff --git a/Gemfile.lock b/Gemfile.lock index 9d611326..660119e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -226,7 +226,7 @@ GEM net-ssh (5.2.0) netaddr (2.0.3) nio4r (2.3.1) - nokogiri (1.10.3) + nokogiri (1.10.4) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) parallel (1.17.0) diff --git a/app/assets/javascripts/markdown.js b/app/assets/javascripts/markdown.js deleted file mode 100644 index 2927b9fd..00000000 --- a/app/assets/javascripts/markdown.js +++ /dev/null @@ -1,4 +0,0 @@ -$(document).on('turbolinks:load', function() { - var md = window.markdownit(); - $('#post_content').markdown({ parser: md.render.bind(md) }); -}); diff --git a/app/assets/javascripts/validation.js b/app/assets/javascripts/validation.js index b0769adf..999bf2f0 100644 --- a/app/assets/javascripts/validation.js +++ b/app/assets/javascripts/validation.js @@ -1,4 +1,5 @@ $(document).on('turbolinks:load', function() { + // Previene el envío del formulario al presionar $(document).on('keypress', '.post :input:not(textarea):not([type=submit])', function(e) { if (e.keyCode == 13) { e.preventDefault(); @@ -6,6 +7,7 @@ $(document).on('turbolinks:load', function() { } }); + // Al enviar el formulario del artículo, aplicar la validación $('.submit-post').click(function(e) { var form = $(this).parents('form.form'); var invalid_help = $('.invalid_help'); @@ -13,11 +15,22 @@ $(document).on('turbolinks:load', function() { invalid_help.addClass('d-none'); sending_help.addClass('d-none'); + form.find('[aria-invalid="true"]') + .attr('aria-invalid', false) + .attr('aria-describedby', function() { + return $(this).siblings('.feedback').attr('id'); + }); if (form[0].checkValidity() === false) { e.preventDefault(); e.stopPropagation(); invalid_help.removeClass('d-none'); + + form.find(':invalid') + .attr('aria-invalid', true) + .attr('aria-describedby', function() { + return $(this).siblings('.invalid-feedback').attr('id'); + }); } else { sending_help.removeClass('d-none'); } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e4119a92..9e29ead4 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,3 +1,9 @@ +// TODO: Encontrar la forma de generar esto desde los locales de Rails +$custom-file-text: ( + en: 'Browse', + es: 'Buscar archivo' +); + @import "bootstrap"; @import "bootstrap-markdown/css/bootstrap-markdown.min"; @import "font-awesome"; diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 1e5dd49e..d4ea3f9b 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -7,11 +7,12 @@ class PostsController < ApplicationController def index authorize Post - @site = find_site - @lang = find_lang(@site) + @site = find_site + # TODO: por qué no lo está leyendo @site.posts? + @site.read @category = session[:category] = params.dig(:category) - @posts = policy_scope(@site.posts_for(@lang), - policy_scope_class: PostPolicy::Scope) + # TODO: Aplicar policy_scope + @posts = @site.posts(lang: I18n.locale) if params[:sort_by].present? begin @@ -33,12 +34,8 @@ class PostsController < ApplicationController def new authorize Post @site = find_site - @lang = find_lang(@site) - @template = find_template(@site) - @post = Post.new(site: @site, - front_matter: { date: Time.now }, - lang: @lang, - template: @template) + # TODO: Implementar layout + @post = @site.posts.build(lang: I18n.locale) end def create diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8be0d41e..db667923 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,26 +2,23 @@ # Helpers module ApplicationHelper - # Devuelve el atributo name de un campo posiblemente anidado - def field_name_for_post(names) - return ['post', names] if names.is_a? String - - names = names.dup - root = 'post' + # Devuelve el atributo name de un campo anidado en el formato que + # esperan los helpers *_field + # + # [ 'post', :image, :description ] + # [ 'post[image]', :description ] + # 'post[image][description]' + def field_name_for(*names) name = names.pop + root = names.shift + names.each do |n| - root = "#{root}[#{n}]" + root += "[#{n}]" end [root, name] end - def field_name_for_post_as_string(names) - f = field_name_for_post(names) - - "#{f.first}[#{f.last}]" - end - def distance_of_time_in_words_if_more_than_a_minute(seconds) if seconds > 60 distance_of_time_in_words seconds @@ -49,4 +46,53 @@ module ApplicationHelper def form_class(model) model.errors.messages.empty? ? 'needs-validation' : 'was-validated' end + + # Opciones por defecto para el campo de un formulario + def field_options(attribute, metadata) + { + class: 'form-control', + required: metadata.required, + aria: { + describedby: id_for_help(attribute), + required: metadata.required + } + } + end + + # Devuelve la clase is-invalid si el campo tiene un error + def invalid(post, attribute) + 'is-invalid' if post.errors[attribute].present? + end + + # Busca la traducción de una etiqueta en los metadatos de un post + def post_label_t(*attribute, post:) + label = post_t(*attribute, post: post, type: :label) + + if post.send(attribute.first).required + label += I18n.t('posts.attributes.required.label') + end + + label + end + + def post_help_t(*attribute, post:) + post_t(*attribute, post: post, type: :help) + end + + def id_for_help(*attribute) + "#{attribute.join('-')}-help" + end + + def id_for_feedback(*attribute) + "#{attribute.join('-')}-feedback" + end + + private + + 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}") + end end diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb new file mode 100644 index 00000000..ec5284ba --- /dev/null +++ b/app/models/metadata_content.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Se encarga del contenido del artículo y quizás otros campos que +# requieran texto largo. +class MetadataContent < MetadataTemplate + include ActionView::Helpers::SanitizeHelper + + def default_value + '' + end + + def value + sanitize(self[:value] || document.content || default_value, + sanitize_options) + end + + private + + # Etiquetas y atributos HTML a permitir + # + # No queremos permitir mucho más que cosas de las que nos falten en + # CommonMark. + # + # TODO: Permitir una lista de atributos y etiquetas en el Layout + # + # XXX: Vamos a generar un reproductor de video/audio directamente + # desde un plugin de Jekyll + def sanitize_options + { + tags: %w[span], + attributes: %w[title class lang] + } + end +end diff --git a/app/models/metadata_date.rb b/app/models/metadata_date.rb index 2c4741f3..ea0bad50 100644 --- a/app/models/metadata_date.rb +++ b/app/models/metadata_date.rb @@ -1,3 +1,4 @@ -class MetadataDate < MetadataTemplate +# frozen_string_literal: true +class MetadataDate < MetadataTemplate end diff --git a/app/models/metadata_image.rb b/app/models/metadata_image.rb index 817914d2..c0bbebd5 100644 --- a/app/models/metadata_image.rb +++ b/app/models/metadata_image.rb @@ -4,7 +4,7 @@ class MetadataImage < MetadataTemplate # Una ruta vacía a la imagen con una descripción vacía def default_value - { path: '', description: '' } + { 'path' => '', 'description' => '' } end def empty? diff --git a/app/models/metadata_path.rb b/app/models/metadata_path.rb index efb7baf6..b3f3b1a5 100644 --- a/app/models/metadata_path.rb +++ b/app/models/metadata_path.rb @@ -17,6 +17,10 @@ class MetadataPath < MetadataTemplate Pathname.new(value).relative_path_from(Pathname.new(site.path)).to_s end + def basename + File.basename(value, ext) + end + private def ext diff --git a/app/models/metadata_string.rb b/app/models/metadata_string.rb index 4cc2ff1f..8d39d5d4 100644 --- a/app/models/metadata_string.rb +++ b/app/models/metadata_string.rb @@ -6,4 +6,8 @@ class MetadataString < MetadataTemplate def default_value '' end + + def value + super.strip + end end diff --git a/app/models/post.rb b/app/models/post.rb index 86a0d052..de506879 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -10,10 +10,10 @@ require 'jekyll/utils' # rubocop:disable Style/MissingRespondToMissing class Post < OpenStruct # Atributos por defecto - # XXX: Volver document opcional cuando estemos creando DEFAULT_ATTRIBUTES = %i[site document layout].freeze # Otros atributos que no vienen en los metadatos - ATTRIBUTES = %i[content lang path date slug attributes errors].freeze + PRIVATE_ATTRIBUTES = %i[lang path slug attributes errors].freeze + PUBLIC_ATTRIBUTES = %i[date].freeze # Redefinir el inicializador de OpenStruct # @@ -27,15 +27,8 @@ class Post < OpenStruct super(args) # Genera un método con todos los atributos disponibles - self.attributes = DEFAULT_ATTRIBUTES + - ATTRIBUTES + - layout.metadata.keys.map(&:to_sym) - - # El contenido - # TODO: Mover a su propia clase para poder hacer limpiezas - # independientemente - self.content = document.content - self.errors = {} + self.attributes = layout.metadata.keys.map(&:to_sym) + PUBLIC_ATTRIBUTES + self.errors = {} # Genera un atributo por cada uno de los campos de la plantilla, # MetadataFactory devuelve un tipo de campo por cada campo. A @@ -63,6 +56,10 @@ class Post < OpenStruct end # rubocop:enable Metrics/AbcSize + def id + path.basename + end + # Levanta un error si al construir el artículo no pasamos un atributo. def default_attributes_missing(**args) DEFAULT_ATTRIBUTES.each do |attr| @@ -100,10 +97,11 @@ class Post < OpenStruct # Detecta si es un atributo válido o no, a partir de la tabla de la # plantilla def attribute?(mid) + attrs = DEFAULT_ATTRIBUTES + PRIVATE_ATTRIBUTES + PUBLIC_ATTRIBUTES if singleton_class.method_defined? :attributes - attributes.include? attribute_name(mid) + (attrs + attributes).include? attribute_name(mid) else - (DEFAULT_ATTRIBUTES + ATTRIBUTES).include? attribute_name(mid) + attrs.include? attribute_name(mid) end end @@ -119,7 +117,7 @@ class Post < OpenStruct # Asegurarse que haya un layout yaml['layout'] = layout.name.to_s - "#{yaml.to_yaml}---\n\n#{content}" + "#{yaml.to_yaml}---\n\n#{content.value}" end # Eliminar el artículo del repositorio y de la lista de artículos del diff --git a/app/models/post_relation.rb b/app/models/post_relation.rb index 060a9149..fe122d6c 100644 --- a/app/models/post_relation.rb +++ b/app/models/post_relation.rb @@ -32,10 +32,10 @@ class PostRelation < Array private - def build_layout(layout = :post) + def build_layout(layout = nil) return layout if layout.is_a? Layout - site.layouts[layout] + site.layouts[layout || :post] end # Devuelve una colección Jekyll que hace pasar el documento diff --git a/app/models/site.rb b/app/models/site.rb index c4fc5fd8..ac95651c 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -162,7 +162,7 @@ class Site < ApplicationRecord @layouts ||= data.fetch('layouts', {}).map do |name, metadata| { name.to_sym => Layout.new(site: self, name: name.to_sym, - metadata: metadata) } + metadata: metadata.with_indifferent_access) } end.inject(:merge) end diff --git a/app/policies/post_policy.rb b/app/policies/post_policy.rb index 103423f0..b089aecc 100644 --- a/app/policies/post_policy.rb +++ b/app/policies/post_policy.rb @@ -57,12 +57,14 @@ class PostPolicy # Las usuarias pueden ver todos los posts # # Les invitades solo pueden ver sus propios posts + # + # TODO: Arreglar def resolve return scope if scope.try(:first).try(:site).try(:usuarie?, usuarie) # Asegurarse que al menos devolvemos [] [scope.find do |post| - post.author == usuarie.email + post.author.value == usuarie.email end].flatten.compact end end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 8c96f8c4..2db6992c 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! -%html +%html{ lang: I18n.locale, dir: t('dir') } %head %meta{ content: 'text/html; charset=UTF-8', 'http-equiv': 'Content-Type' }/ @@ -11,10 +11,7 @@ = javascript_include_tag 'application', 'data-turbolinks-track': 'reload' - - if @site.try(:persisted?) && @site.try(:config).try(:dig, 'css') - %link{ rel: 'stylesheet', - type: 'text/css', - href: @site.get_url_from_site(@site.config.dig('css')) } + -# TODO: Reimplementar get_url_from_site - style = "background-image: url(#{@site.try(:cover)})" -# haml-lint:disable InlineStyles diff --git a/app/views/posts/_attribute_feedback.haml b/app/views/posts/_attribute_feedback.haml new file mode 100644 index 00000000..a1342c6a --- /dev/null +++ b/app/views/posts/_attribute_feedback.haml @@ -0,0 +1,5 @@ +%small.feedback.form-text.text-muted{ id: id_for_help(*attribute) } + = post_help_t(*attribute, post: post) +- if metadata.required + .invalid-feedback{ id: id_for_feedback(*attribute) } + = t('posts.attributes.required.feedback') diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml index 3b6aee0a..d098ded4 100644 --- a/app/views/posts/_form.haml +++ b/app/views/posts/_form.haml @@ -1,144 +1,34 @@ -- unless @post.errors.empty? +- unless post.errors.empty? .alert.alert-danger %ul - - @post.errors.each do |key, error| + - post.errors.each do |key, error| %li - %strong= @post.template_fields.find { |tf| tf.key == key.to_s }.try(:label) || key + %strong + = key.capitalize = [error].flatten.join("\n") --# TODO seleccionar la dirección por defecto según el idioma actual -- direction = @post.get_front_matter('dir') || 'ltr' --# string para configurar la clase con direccion de texto -- field_class = "form-control #{direction}" --# TODO habilitar form_for -- if @post.new? - - url = site_posts_path(@site, lang: @lang) - - method = :post -- else - - url = site_post_path(@site, @post, lang: @lang) - - method = :patch -- if pre = @post.template.try(:get_front_matter, 'pre') - = render 'layouts/help', help: CommonMarker.render_doc(pre).to_html -= form_tag url, - method: method, - class: "form post #{@invalid ? 'was-validated' : ''}", - novalidate: true, - multipart: true do +-# TODO: habilitar form_for +:ruby + if post.new? + url = site_posts_path(site) + method = :post + else + url = site_post_path(site, post) + method = :patch + end - = hidden_field_tag 'template', params[:template] - .form-group - = submit_tag t('posts.save'), class: 'btn btn-success submit-post' - = submit_tag t('posts.save_incomplete'), class: 'btn btn-info submit-post-incomplete', name: 'commit_incomplete' - .invalid_help.alert.alert-danger.d-none= @site.config.dig('invalid_help') || t('posts.invalid_help') - .sending_help.alert.alert-success.d-none= @site.config.dig('sending_help') || t('posts.sending_help') - - if @site.usuarie? current_user - .form-group - = label_tag 'post_author', t('posts.author') - - todxs = (@site.usuaries + @site.invitades).compact.uniq.map(&:email) - = select_tag 'post[author]', - options_for_select(todxs, @post.author || current_user.email), - { class: 'form-control select2', - data: { tags: true, - placeholder: t('posts.select.placeholder'), - 'allow-clear': true } } - %small.text-muted.form-text= t('posts.author_help') - - if @post.has_field? :dir - .form-group - = label_tag 'post_dir', t('posts.dir') - = select_tag 'post[dir]', - options_for_select([[t('posts.ltr'), 'ltr'], [t('posts.rtl'), 'rtl']], direction), - { class: 'form-control' } - %small.text-muted.form-text= t('posts.dir_help') - - if @post.has_field? :title - .form-group - = label_tag 'post_title', t('posts.title') - = text_field 'post', 'title', value: @post.title, class: field_class, required: true - - if @post.content? - .form-group{class: direction} - = label_tag 'post_content', t('posts.content') - = render 'layouts/help', help: [ t('help.markdown.intro'), - t('help.distraction_free_html'), - t('help.preview_html') ] - = text_area_tag 'post[content]', @post.content, - class: 'post-content' - - if @post.has_field? :date - .form-group - = label_tag 'post_date', t('posts.date') - = date_field 'post', 'date', value: @post.date.try(:strftime, '%F'), - class: 'form-control' - %small.text-muted.form-text= t('posts.date_help') - = render 'layouts/help', help: t('help.autocomplete_html') - - if @post.has_field? :categories - .form-group - = label_tag 'post_categories', t('posts.categories') - = select_tag 'post[categories][]', - options_for_select(@site.categories(lang: @lang), @post.categories), - { class: 'form-control select2', multiple: 'multiple', - data: { tags: true, - placeholder: t('posts.select.placeholder'), - 'allow-clear': true } } - - if @post.has_field? :tags - .form-group - = label_tag 'post_tags', t('posts.tags') - = select_tag 'post[tags][]', - options_for_select(@site.tags(lang: @lang), @post.tags), - { class: 'form-control select2', multiple: 'multiple', - data: { tags: true, - placeholder: t('posts.select.placeholder'), - 'allow-clear': true } } - - if @post.has_field? :slug - .form-group - = label_tag 'post_slug', t('posts.slug') - = text_field 'post', 'slug', value: @post.slug, - class: 'form-control' - %small.text-muted.form-text= t('posts.slug_help') - - if @post.has_field? :permalink - .form-group - = label_tag 'post_permalink', t('posts.permalink') - = text_field 'post', 'permalink', value: @post.get_front_matter('permalink'), - class: 'form-control' - %small.text-muted.form-text= t('posts.permalink_help') - - if @post.has_field? :layout - .form-group - = label_tag 'post_layout', t('posts.layout') - = select_tag 'post[layout]', - options_for_select(@site.layouts, @post.get_front_matter('layout')), - { class: 'form-control select2' } - %small.text-muted.form-text= t('posts.layout_help') - - if @site.i18n? - - @site.translations.each do |lang| - - next if lang == @lang - .form-group - = label_tag 'post_lang', t("posts.lang.#{lang}") - = select_tag "post[lang][#{lang}]", - options_for_select(@site.posts_for(lang).map { |p| [p.title, p.id] }, - @post.get_front_matter('lang').try(:dig, lang)), - { class: 'form-control select2' } - %small.text-muted.form-text= t('posts.lang_help') - -# Genera todos los campos de la plantilla - - @post.template_fields.each do |template| - - next unless type = template.type - - if template.title.present? - %h1{id: template.title.tr(' ', '_').titleize}= template.title - - if template.subtitle.present? - %p= template.subtitle - - value = @post.new? ? template.values : @post.get_front_matter(template.key) - .form-group - = label_tag "post_#{template}", id: template do - = link_to '#' + template.key, class: 'text-muted', - data: { turbolinks: 'false' } do - = fa_icon 'link', title: t('posts.anchor') - - if template.private? - = fa_icon 'lock', title: t('posts.private') - = sanitize_markdown template.label, tags: %w[a] - - if template.help - %small.text-muted.form-text= template.help - = render "posts/template_field/#{type}", template: template, name: template.key, value: value - .invalid-feedback= t('posts.invalid') - .form-group - = submit_tag t('posts.save'), class: 'btn btn-success submit-post' - = submit_tag t('posts.save_incomplete'), class: 'btn btn-info submit-post-incomplete', name: 'commit_incomplete' - .invalid_help.alert.alert-danger.d-none= @site.config.dig('invalid_help') || t('posts.invalid_help') - .sending_help.alert.alert-success.d-none= @site.config.dig('sending_help') || t('posts.sending_help') -- if post = @post.template.try(:get_front_matter, 'post') - = render 'layouts/help', help: CommonMarker.render_doc(post).to_html +-# Comienza el formulario += form_tag url, method: method, class: 'form post', multipart: true do + + -# Botones de guardado + = render 'posts/submit', site: site + + -# Dibuja cada atributo + - post.attributes.each do |attribute| + - type = post.send(attribute).type + = render "posts/attributes/#{type}", + post: post, attribute: attribute, + metadata: post.send(attribute) + + -# Botones de guardado + = render 'posts/submit', site: site diff --git a/app/views/posts/_submit.haml b/app/views/posts/_submit.haml new file mode 100644 index 00000000..261631ee --- /dev/null +++ b/app/views/posts/_submit.haml @@ -0,0 +1,9 @@ +.form-group + = submit_tag t('.save'), class: 'btn btn-success submit-post' + = submit_tag t('.save_incomplete'), + class: 'btn btn-info submit-post-incomplete', + name: 'commit_incomplete' + .invalid_help.alert.alert-danger.d-none + = site.config.fetch('invalid_help', t('.invalid_help')) + .sending_help.alert.alert-success.d-none + = site.config.fetch('sending_help', t('.sending_help')) diff --git a/app/views/posts/attributes/_array.haml b/app/views/posts/attributes/_array.haml new file mode 100644 index 00000000..aab5af18 --- /dev/null +++ b/app/views/posts/attributes/_array.haml @@ -0,0 +1,7 @@ +-# TODO: Convertir a select2 o nuestro reemplazo +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}", post_label_t(attribute, post: post) + = text_field 'post', attribute, value: metadata.value.join(', '), + **field_options(attribute, metadata) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml new file mode 100644 index 00000000..ee226d35 --- /dev/null +++ b/app/views/posts/attributes/_content.haml @@ -0,0 +1,6 @@ +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}", post_label_t(attribute, post: post) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata + = text_area_tag "post[#{attribute}]", metadata.value, + **field_options(attribute, metadata) diff --git a/app/views/posts/attributes/_document_date.haml b/app/views/posts/attributes/_document_date.haml new file mode 100644 index 00000000..cc9b46fe --- /dev/null +++ b/app/views/posts/attributes/_document_date.haml @@ -0,0 +1,6 @@ +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}", post_label_t(attribute, post: post) + = date_field 'post', attribute, value: metadata.value.strftime('%F'), + **field_options(attribute, metadata) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/attributes/_image.haml b/app/views/posts/attributes/_image.haml new file mode 100644 index 00000000..5922e423 --- /dev/null +++ b/app/views/posts/attributes/_image.haml @@ -0,0 +1,20 @@ +.form-group{ class: invalid(post, attribute) } + - if metadata.value['path'].present? + = image_tag metadata.value[:path], alt: metadata.value['description'] + + .custom-file + = file_field(*field_name_for('post', attribute, :path), + **field_options(attribute, metadata), class: 'custom-file-input') + = label_tag "post_#{attribute}_path", + post_label_t(attribute, :path, post: post), class: 'custom-file-label' + = render 'posts/attribute_feedback', + post: post, attribute: [attribute, :path], metadata: metadata + +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}_description", + post_label_t(attribute, :description, post: post) + = text_field(*field_name_for('post', attribute, :description), + value: metadata.value['description'], + **field_options(attribute, metadata)) + = render 'posts/attribute_feedback', + post: post, attribute: [attribute, :description], metadata: metadata diff --git a/app/views/posts/attributes/_slug.haml b/app/views/posts/attributes/_slug.haml new file mode 100644 index 00000000..d0f7b4d1 --- /dev/null +++ b/app/views/posts/attributes/_slug.haml @@ -0,0 +1,6 @@ +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}", post_label_t(attribute, post: post) + = text_field 'post', attribute, value: metadata.value, + **field_options(attribute, metadata) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/attributes/_string.haml b/app/views/posts/attributes/_string.haml new file mode 100644 index 00000000..d0f7b4d1 --- /dev/null +++ b/app/views/posts/attributes/_string.haml @@ -0,0 +1,6 @@ +.form-group{ class: invalid(post, attribute) } + = label_tag "post_#{attribute}", post_label_t(attribute, post: post) + = text_field 'post', attribute, value: metadata.value, + **field_options(attribute, metadata) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index 0b266625..7beea4b0 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -1,44 +1,21 @@ .row .col = render 'layouts/breadcrumb', - crumbs: [ link_to(t('sites.index'), sites_path), @site.name, link_to(t('posts.index'), site_posts_path(@site)), @category ] + crumbs: [link_to(t('sites.index'), sites_path), + @site.name, + link_to(t('posts.index'), + site_posts_path(@site)), + @category] = render 'layouts/help', help: t('help.breadcrumbs') .row .col - %h1= @site.config.fetch('title', @site.name_with_i18n(@lang)) + %h1= @site.title .row .col .btn-group - - if @site.templates.empty? - = link_to t('posts.new'), new_site_post_path(@site, lang: @lang), - class: 'btn btn-success' - - else - = link_to t('posts.new_with_template', template: @site.templates.first.id.humanize), - new_site_post_path(@site, lang: @lang, template: @site.templates.first.id), - class: 'btn btn-success' - - if @site.usuarie? current_usuarie - %button.btn.btn-success.dropdown-toggle.dropdown-toggle-split{data: { toggle: 'split' }, - aria: { haspopup: 'true', expanded: 'false' }} - %span.sr-only= t('posts.dropdown') - .dropdown-menu - - @site.templates.each do |template| - = link_to template.id.humanize, - new_site_post_path(@site, lang: @lang, template: template.id), - class: 'dropdown-item' - - @site.translations.each do |l| - = link_to t("i18n.#{l}"), site_posts_path(@site, category: @category, lang: l), - class: 'btn btn-info' - .btn-group.pull-right - = link_to t('posts.categories'), site_posts_path(@site, lang: @lang), class: 'btn btn-secondary' - %button.btn.btn-secondary.dropdown-toggle.dropdown-toggle-split{data: { toggle: 'split' }, - aria: { haspopup: 'true', expanded: 'false' }} - %span.sr-only= t('posts.dropdown') - .dropdown-menu - - @site.categories.each do |c| - = link_to c.split(':').first, - site_posts_path(@site, lang: @lang, category: c), - class: (params[:category] == c) ? 'dropdown-item active' : 'dropdown-item' + = link_to t('posts.new'), new_site_post_path(@site), + class: 'btn btn-success' .row .col @@ -54,80 +31,35 @@ category: @category, lang: @lang, sort_by: s) - = form_tag site_reorder_posts_path, method: :post do - = hidden_field 'posts', 'lang', value: @lang - - if policy(@site).reorder_posts? - - if @site.ordered? @lang - .reorder-posts-panel.alert.alert-info.alert-dismissible.fade.show{role: 'alert'} - = raw t('help.posts.reorder') - %br - = submit_tag t('posts.reorder_posts'), class: 'btn btn-success' - %button.close{type: 'button', - 'aria-label': t('help.close') } - %span{'aria-hidden': true} × - - else - .alert.alert-danger.alert-dismissible.fade.show{role: 'alert'} - = raw t('errors.posts.disordered') - %br - = hidden_field 'posts', 'force', value: true - = submit_tag t('errors.posts.disordered_button'), class: 'btn btn-danger' - %button.close{type: 'button', - data: { dismiss: 'alert' }, - 'aria-label': t('help.close') } - %span{'aria-hidden': true} × - %table.table.table-condensed.table-striped{class: (@site.ordered? @lang) ? 'table-draggable' : ''} - %tbody - - @posts.each_with_index do |post, i| - - if @category - -# saltearse el post a menos que esté en la categoría - -# por la que estamos filtrando - - next unless post.categories.include?(@category) - -# establecer la direccion del texto - - direction = post.get_front_matter(:dir) - %tr - - if policy(@site).reorder_posts? && @site.ordered?(@lang) + %table.table.table-condensed.table-striped + %tbody + - @posts.each do |post| + -# + saltearse el post a menos que esté en la categoría por + la que estamos filtrando + - if @category + - next unless post.categories.value.include?(@category) + %tr %td - = fa_icon 'arrows-v', class: 'handle' - = hidden_field 'posts[order]', i, value: post.order, class: 'post_order' - %small + = link_to post.title.value, + site_post_path(@site, post.id) + - unless post.categories.value.empty? %br - %span.order.is= post.order - %span.order.was.d-none{data: { order: post.order }}= "(#{post.order})" + %small + - post.categories.value.each do |c| + = link_to c, site_posts_path(@site, category: c) - %td{class: direction} - = link_to post.title, site_post_path(@site, post, lang: @lang) - - unless post.categories.empty? - %br - %small - - post.categories.each do |c| - = link_to c, site_posts_path(@site, category: c, lang: @lang), - data: { toggle: 'tooltip' }, title: t('help.category') - - if post.draft? || post.incomplete? - %br - - if post.draft? - %span.badge.badge-info= t('posts.draft') - - if post.incomplete? - %span.badge.badge-warning= t('posts.incomplete') - - %td - - if post.translations - %small - - post.translations.each do |pt| - = link_to pt.title, site_post_path(@site, pt, lang: pt.lang), - data: { toggle: 'tooltip' }, title: t("i18n.#{pt.lang}") - %br - - %td= post.date.strftime('%F') - %td - - if policy(post).edit? - = link_to t('posts.edit'), - edit_site_post_path(@site, post, lang: @lang), - class: 'btn btn-info' - - if policy(post).destroy? - = link_to t('posts.destroy'), - site_post_path(@site, post, lang: @lang), - class: 'btn btn-danger', - method: :delete, - data: { confirm: t('posts.confirm_destroy') } + %td= post.date.value.strftime('%F') + %td + - if policy(post).edit? + = link_to t('posts.edit'), + edit_site_post_path(@site, post.id), + class: 'btn btn-info' + - if policy(post).destroy? + = link_to t('posts.destroy'), + site_post_path(@site, post.id), + class: 'btn btn-danger', + method: :delete, + data: { confirm: t('posts.confirm_destroy') } - else %h2= t('posts.none') diff --git a/app/views/posts/new.haml b/app/views/posts/new.haml index dea3dd72..184227b6 100644 --- a/app/views/posts/new.haml +++ b/app/views/posts/new.haml @@ -1,6 +1,11 @@ .row .col - = render 'layouts/breadcrumb', crumbs: [ link_to(t('sites.index'), sites_path), @site.name, link_to(t('posts.index'), site_posts_path(@site)), t('posts.new') ] + = render 'layouts/breadcrumb', + crumbs: [link_to(t('sites.index'), sites_path), + @site.name, + link_to(t('posts.index'), + site_posts_path(@site)), t('posts.new')] + .row.justify-content-center .col-md-8 - = render 'posts/form' + = render 'posts/form', site: @site, post: @post diff --git a/config/locales/en.yml b/config/locales/en.yml index 2c08ee14..ad57552b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,5 @@ en: + dir: ltr site_service: create: 'Created %{name}' update: 'Updated %{name}' @@ -53,6 +54,8 @@ en: lang: 'Main language' site: name: 'Name' + title: 'Title' + description: 'Description' errors: models: site: @@ -297,6 +300,17 @@ en: en: 'English' ar: 'Arabic' posts: + submit: + save: 'Save' + save_incomplete: 'Save as draft' + invalid_help: 'Some fields need attention! Please search for the fields marked as invalid.' + attributes: + date: + label: Date + help: Publication date for this post. If you use a date in the future the post won't be published until then. + required: + label: ' (required)' + feedback: 'This field cannot be empty!' reorder_posts: 'Reorder posts' sort: by: 'Sort by' @@ -310,59 +324,8 @@ en: categories: 'Everything' index: 'Posts' edit: 'Edit' - save: 'Send' - save_incomplete: 'Save for later' draft: revision incomplete: draft - author: 'Author' - author_help: 'You can change the authorship of the post. If your site accepts guests, changing the authorship to an e-mail address will allow them to edit the post.' - date: 'Publication date' - date_help: 'This changes the articles order!' - title: 'Title' - tags: 'Tags' - tags_help: 'Comma separated!' - tags: 'Tags' - slug: 'Slug' - slug_help: 'This is the name of the article on the URL, ie. /title/. You can leave it empty. If you changed the title and you want to change the file name, empty this field.' - cover: 'Cover' - cover_help: 'Path to the cover' - layout: 'Layout' - layout_help: 'The layout of this post' - objetivos: 'Objectives' - objetivos_help: 'Objectives of this session' - permalink: 'Permanent link' - permalink_help: "If you want to access the post from a specific URL, use this field. Don't forget to start with a /" - recomendaciones: 'Recommendations' - recomendaciones_help: 'Recommendations for this session' - duracion: 'Duration' - duracion_help: "How long does the session take to finish?" - habilidades: 'Skill level' - habilidades_help: 'Skills required for this session' - formato: 'Format' - formato_help: 'Format of this session' - conocimientos: 'Required knowledge' - conocimientos_help: 'Select all required knowledge for this session' - sesiones_ejercicios_relacionados: 'Related sessions/exercises' - sesiones_ejercicios_relacionados_help: 'Select all related sessions/exercises' - materiales_requeridos: 'Needed materials' - materiales_requeridos_help: 'Select all materials needed for this session' - lang: - es: 'Castillian Spanish' - en: 'English' - ar: 'Arabic' - lang_help: 'The same article in another language.' - rtl: 'Right to left' - ltr: 'Left to right' - dir: 'Text direction' - dir_help: 'The reading direction of the language' - logger: - rm: 'Removed %{path}' - errors: - path: 'File already exist' - file: "Couldn't write the file" - title: 'Post needs a title' - date: 'Post needs a valid date' - slug_with_path: "The slug is the short name for the article, as shown in the URL. It can't contain \"/\" ;)" invalid: 'This field is required!' open: 'Tip: You can add new options by typing them and pressing Enter' private: '🔒 The values of this field will remain private' diff --git a/config/locales/es.yml b/config/locales/es.yml index f7fe475d..f5a2b9b5 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,5 @@ es: + dir: ltr site_service: create: 'Creado %{name}' update: 'Actualizado %{name}' @@ -312,6 +313,17 @@ es: en: 'inglés' ar: 'árabe' posts: + submit: + save: 'Guardar' + save_incomplete: 'Guardar como borrador' + invalid_help: '¡Te faltan completar algunos campos! Busca los que estén marcados como inválidos' + attributes: + date: + label: Fecha + help: La fecha de publicación del artículo. Si colocas una fecha en el futuro no se publicará hasta ese día. + required: + label: ' (requerido)' + feedback: '¡Este campo no puede estar vacío!' reorder_posts: 'Reordenar artículos' sort: by: 'Ordenar por' @@ -327,60 +339,6 @@ es: edit: 'Editar' draft: en revisión incomplete: borrador - save: 'Enviar' - save_incomplete: 'Guardar para después' - author: 'Autorx' - author_help: 'Puedes cambiar la autoría del artículo aquí. Si el sitio acepta invitadxs, poner la dirección de correo de alguien aquí le permite editarlo.' - date: 'Fecha de publicación' - date_help: '¡Esto cambia el orden de los artículos!' - title: 'Título' - categories: 'Categorías' - tags: 'Etiquetas' - slug: 'Nombre la URL' - slug_help: 'Esto es el nombre del artículo en la URL, por ejemplo - /título/. Puedes dejarlo vacío. Si cambiaste el título y quieres - que la URL cambie, borra el contenido de este campo.' - cover: 'Portada' - cover_help: 'La dirección de la portada' - layout: 'Plantilla' - layout_help: 'El tipo de plantilla' - objetivos: 'Objetivos' - objetivos_help: 'Objetivos de esta sesión' - permalink: 'Dirección del enlace' - permalink_help: 'Si quieres que el artículo se acceda desde esta URL completa aquí, no te olvides de empezar con /' - habilidades: 'Habilidades' - habilidades_help: 'Habilidades requeridas para esta sesión' - formato: 'Formato' - formato_help: 'Formato de esta sesión' - conocimientos: 'Conocimientos' - conocimientos_help: 'Elige todos los conocimientos requeridos para abordar esta sesión' - sesiones_ejercicios_relacionados: 'Sesiones y ejercicios relacionados' - sesiones_ejercicios_relacionados_help: 'Elige todas las sesiones relacionadas con esta' - materiales_requeridos: 'Materiales requeridos' - materiales_requeridos_help: 'Materiales necesarios para esta sesión' - recomendaciones: 'Recomendaciones' - recomendaciones_help: 'Recomendaciones para esta sesión' - duracion: 'Duración' - duracion_help: '¿Cuánto dura la sesión?' - lang: - es: 'Artículo en castellano' - en: 'Artículo en inglés' - ar: 'Artículo en árabe' - lang_help: 'El mismo artículo en otro idioma' - rtl: 'Derecha a izquierda' - ltr: 'Izquierda a derecha' - dir: 'Dirección del texto' - dir_help: 'La dirección de lectura del idioma' - dir_help: 'Cambiar la dirección del texto, por ej. el árabe se lee de derecha a izquierda' - logger: - rm: 'Eliminado %{path}' - errors: - path: 'El archivo destino ya existe' - file: 'No se pudo escribir el archivo' - title: 'Necesita un título' - date: 'Necesita una fecha' - slug_with_path: 'El slug es el nombre corto del artículo, tal como figura en la URL. No puede contener "/" ;)' - invalid: '¡Este campo es obligatorio!' open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar' private: '🔒 Los valores de este campo serán privados' select: diff --git a/doc/posts.md b/doc/posts.md index ddb1fbb2..852f351b 100644 --- a/doc/posts.md +++ b/doc/posts.md @@ -98,3 +98,11 @@ Al instanciar un `Post`, se pasan el sitio y la plantilla por defecto. utilizada) * Reimplementar orden de artículos (ver doc) * Convertir layout a params +* Reimplementar plantillas +* Reimplementar subida de imagenes/archivos +* Reimplementar campo 'pre' y 'post' en los layouts.yml +* Implementar autoría como un array +* Reimplementar draft e incomplete (por qué eran distintos?) + +* Convertir idiomas disponibles a pestañas? +* Implementar traducciones sin adivinar. Vincular artículos entre sí diff --git a/test/models/post_test.rb b/test/models/post_test.rb index a5236f17..a0ea5eb0 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -162,6 +162,7 @@ class PostTest < ActiveSupport::TestCase test 'se pueden crear nuevos' do post = @site.posts.build(layout: :post) post.title.value = 'test' + post.content.value = 'test' assert post.new? assert post.save