Pular para o conteúdo
Development

Um painel para posts que eu ainda não publiquei

Por Victor Da Luz
railsrubydev-logblog-manager

Depois que o scanner ligou a sincronização com o GitHub, o blog-manager finalmente tinha posts no banco de dados. Cerca de 100 deles, todos no estado not_imported / not_posted. Hora de colocar uma UI em cima.

O que isso precisa ser

O PRD pede uma única tela que liste todo post de todo blog registrado, filtrável por blog, status no Medium e status no LinkedIn, ordenável por data de publicação. Nada sofisticado. O objetivo deste app é me tirar do hábito de alternar entre abas do GitHub, do Medium e do LinkedIn para descobrir em que ponto do ciclo de vida cada post está, e o painel é o que responde essa pergunta.

Eu tinha duas formas de delimitar o escopo da issue:

  1. Construir a tabela agora, deixando o status de Medium/LinkedIn como um traço em todo lugar porque essas colunas ainda não existem, e deixar issues futuras preenchê-las.
  2. Adicionar as colunas de status de distribuição agora, junto com o painel.

Escolhi a (2). O PRD já tinha fixado o formato dos enums para as duas plataformas. O schema e a UI que o exibe deveriam viver no mesmo commit. Separá-los significaria uma sequência de dois PRs em que nenhuma das metades conta a história completa.

O schema

Uma migration, sem surpresas:

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

Dois enums baseados em inteiro no model, com prefixos para eu não ter colisão de nomes depois:

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

O prefix: importa. Sem ele, o Rails gera published? no Post para o enum do Medium, e um dia vou adicionar um conceito de “published” que não tem nada a ver com o Medium, e essa sobreposição vai me morder. Com prefix: :medium eu tenho medium_published? e medium_draft?, e o model diz exatamente o que significa.

O truque do filtro

O controller pega os valores de filtro dos query params. Rails do jeito ingênuo:

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

Isso funciona até alguém (ou um fuzzer, ou eu mesmo com um typo) mandar ?medium_status=garbage. Com enums baseados em inteiro, o Rails lança ArgumentError: 'garbage' is not a valid medium_status. Com enums baseados em string, você teria um resultado vazio e um usuário confuso.

A correção é fazer allow-list contra as chaves conhecidas do enum antes de passar para o where:

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

Post.medium_statuses retorna o hash {"not_imported"=>0, "draft"=>1, "published"=>2}. key? retorna true só para valores válidos. Entrada inválida passa direto sem erro e o usuário vê a lista sem filtro, que é o padrão certo para uma query string permissiva.

Mesma abordagem para a direção de ordenação:

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

order(pub_date: params[:sort]) injetaria SQL alegremente se você deixasse. A allow-list é uma linha e evita que essa pergunta sequer precise ser feita.

O formulário que se autoenvia

Os filtros são selects. Eu quero que eles apliquem no change, sem precisar de um botão de enviar. O caminho mais curto:

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

O Rails 8 já vem com o Turbo ativado por padrão. form.submit() chamado do JS ainda é interceptado; o Turbo Drive cuida da navegação, a URL é atualizada com os novos params, e a página troca sem reload completo. Nenhum data attribute é necessário.

Cheguei quase a usar Stimulus para isso. O controller inteiro seria três linhas chamando event.target.form.requestSubmit(). O JS puro faz a mesma coisa com menos código. O Stimulus se justifica quando há comportamento de verdade para encapsular; “enviar o formulário no change” não é esse caso.

Testes que exercitam a superfície de URL

Os specs do controller não checam só se a página renderiza. Eles checam a ordem dos títulos no corpo da resposta para confirmar que a ordenação funcionou, e checam que filtrar por blog produz só os posts daquele 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

Fazer index-of no corpo da resposta é bruto. Funciona porque o único lugar onde esses títulos aparecem é nas células da tabela, e isso me diz o que o usuário realmente vê. Um teste que verifica a ordem de assigns(:posts) passaria mesmo que a view esquecesse de renderizá-los.

10 testes de controller, 6 testes de model, tudo verde. Suíte total: 70 runs, 195 assertions.

O que vem a seguir

Este é o quarto commit de feature. O app agora tem: autenticação, gerenciamento de blogs, scanning de posts, painel de posts. A próxima camada é o trabalho por blog: escolher uma imagem do Pexels para um post, enviá-lo ao Medium como rascunho, acompanhar a publicação, postar no LinkedIn. Cada uma dessas etapas se encaixa na tabela que acabei de construir. Elas atualizam valores de enum que o painel já sabe exibir.

Se eu quisesse lançar a v1 hoje, esse painel mais um botão “Importar como rascunho” no Medium já seria o mínimo útil. Mais duas issues de trabalho, três no máximo. Isso é um caminho mais rápido até uma ferramenta funcional do que eu esperava quando comecei esse PRD.

A régua estabelecida pelo scanner continua valendo: 335 linhas novas para essa issue, nenhuma gem nova.

Leitura relacionada

Você também pode achar útil

Proton

Proton Mail

E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais
Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais