Saltar al contenido
Development

Un panel para posts que todavía no publiqué

Por Victor Da Luz
railsrubydev-logblog-manager

Después de que el escáner conectara la sincronización con GitHub, blog-manager por fin tenía posts en su base de datos. Cerca de 100, todos en estado not_imported / not_posted. Hora de ponerles una interfaz encima.

Lo que esto tiene que ser

El PRD pide una sola pantalla que liste todos los posts de todos los blogs registrados, filtrable por blog, estado de Medium y estado de LinkedIn, ordenable por fecha de publicación. Nada complicado. El objetivo de esta app es evitar el cambio constante entre pestañas de GitHub, Medium y LinkedIn para averiguar en qué punto de su ciclo de vida está cada post, y el panel es lo que responde esa pregunta.

Tenía dos maneras de definir el alcance del issue:

  1. Construir la tabla ahora, poner un guion como placeholder para el estado de Medium/LinkedIn en todos lados porque esas columnas todavía no existen, y dejar que issues futuros las completen.
  2. Agregar las columnas de estado de distribución ahora, junto con el panel.

Elegí la (2). El PRD ya fijaba la forma de los enums para ambas plataformas. El esquema y la interfaz que lo muestra tienen que vivir en el mismo commit. Separarlos habría significado una secuencia de dos PRs donde ninguna mitad cuenta la historia completa.

El esquema

Una sola migración, sin sorpresas:

change_table :posts do |t|
  t.integer :medium_status, null: false, default: 0
  t.string  :medium_draft_id
  t.string  :medium_url
  t.datetime :medium_imported_at

  t.integer :linkedin_status, null: false, default: 0
  t.string  :linkedin_post_id
  t.datetime :linkedin_posted_at
  t.text    :linkedin_error
end
add_index :posts, :medium_status
add_index :posts, :linkedin_status

Dos enums respaldados por enteros en el modelo, con prefijos para no toparme con colisiones de nombres más adelante:

enum :medium_status,   { not_imported: 0, draft: 1, published: 2 }, prefix: :medium
enum :linkedin_status, { not_posted:   0, posted: 1, failed: 2 },   prefix: :linkedin

El prefix: importa. Sin él, Rails genera published? en Post para el enum de Medium, y algún día voy a agregar un concepto de “published” que no tiene nada que ver con Medium, y esa colisión me va a morder. Con prefix: :medium obtengo medium_published? y medium_draft?, y el modelo dice exactamente lo que significa.

El truco del filtro

El controlador toma los valores de filtro desde los query params. Rails ingenuo:

scope = scope.where(medium_status: params[:medium_status]) if params[:medium_status].present?

Eso funciona hasta que alguien (o un fuzzer, o yo mismo con un typo) manda ?medium_status=garbage. Con enums respaldados por enteros, Rails lanza ArgumentError: 'garbage' is not a valid medium_status. Con enums respaldados por strings, se obtendría un resultado vacío y un usuario confundido.

La solución es validar contra una lista blanca de las claves conocidas del enum antes de pasarlo a where:

scope = scope.where(medium_status: params[:medium_status]) if Post.medium_statuses.key?(params[:medium_status])

Post.medium_statuses devuelve el hash {"not_imported"=>0, "draft"=>1, "published"=>2}. key? devuelve true solo para valores válidos. Una entrada inválida pasa de largo en silencio y el usuario ve la lista sin filtrar, que es el comportamiento por defecto correcto para un query string permisivo.

El mismo enfoque para la dirección de orden:

ALLOWED_SORTS = %w[asc desc].freeze
direction = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort].to_sym : :desc

order(pub_date: params[:sort]) inyectaría SQL feliz de la vida si se lo permitiera. La lista blanca es una sola línea y evita que esa pregunta siquiera llegue a plantearse.

El formulario que se envía solo

Los filtros son menús desplegables. Quería que se aplicaran al cambiar, sin necesitar un botón de enviar. El camino más corto:

<select name="medium_status" onchange="this.form.submit()">

Rails 8 viene con Turbo activado por defecto. form.submit() desde JS igual queda interceptado; Turbo Drive maneja la navegación, la URL se actualiza con los nuevos params, y la página se reemplaza sin recarga completa. No hace falta ningún atributo data.

Casi recurro a Stimulus para esto. Todo el controlador habría sido tres líneas que llamaban a event.target.form.requestSubmit(). El JS puro hace lo mismo con menos código. Stimulus se justifica cuando hay comportamiento real para encapsular; “enviar el formulario al cambiar” no es ese caso.

Pruebas que ejercitan la superficie de la URL

Los specs del controlador no solo verifican que la página se renderice. Verifican el orden de los títulos en el cuerpo de la respuesta para confirmar que el orden funcionó, y verifican que filtrar por blog produzca solo los posts de ese blog:

test "default sort is pub_date desc" do
  get posts_url
  body = @response.body
  assert body.index("Bravo") < body.index("Charlie"),
    "Bravo (Jun) should come before Charlie (Mar)"
end

test "invalid medium_status is ignored (returns full list)" do
  get posts_url(medium_status: "garbage")
  assert_response :success
  assert_select "td", text: "Alpha"
  assert_select "td", text: "Charlie"
end

Buscar el índice de una cadena en el cuerpo es rudimentario. Funciona porque el único lugar donde aparecen estos títulos es en las celdas de la tabla, y me dice lo que el usuario realmente ve. Una prueba que verificara el orden de assigns(:posts) pasaría incluso si la vista se olvidara de renderizarlos.

10 pruebas de controlador, 6 de modelo, todas en verde. Suite total: 70 corridas, 195 aserciones.

Lo que sigue

Este es el cuarto commit de funcionalidad. La app ahora tiene: autenticación, gestión de blogs, escaneo de posts, panel de posts. La siguiente capa es el trabajo por blog: elegir una imagen de Pexels para un post, enviarlo a Medium como borrador, esperar la publicación, publicarlo en LinkedIn. Cada uno encaja en la tabla que acabo de construir. Actualizan valores de enum que el panel ya sabe mostrar.

Si quisiera publicar la v1 hoy, este panel más un botón de “Import as draft” para Medium sería lo mínimo útil. Dos issues más de trabajo, tres como mucho. Es un camino más rápido hacia una herramienta funcional de lo que esperaba cuando empecé este PRD.

El estándar del escáner se mantiene: 335 líneas nuevas para este issue, sin gemas nuevas.

Lecturas relacionadas

Development

El botón de commit del editor es un botón de deploy

Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.

Leer