A dashboard for posts I haven't published yet
After the scanner wired up GitHub sync, blog-manager finally had posts in its database. About 100 of them, all in not_imported / not_posted state. Time to put a UI on top.
What this needs to be
The PRD calls for one screen that lists every post across every registered blog, filterable by blog, Medium status, and LinkedIn status, sortable by pub date. Nothing fancy. The point of this app is to stop me from context-switching between GitHub, Medium, and LinkedIn tabs to figure out where each post is in its lifecycle, and the dashboard is what answers that question.
I had two ways to scope the issue:
- Build the table now, stub Medium/LinkedIn status as a dash everywhere because those columns don’t exist yet, and let later issues fill them in.
- Add the syndication status columns now alongside the dashboard.
I picked (2). The PRD pinned the enum shapes for both platforms. The schema and the UI that displays it want to live in the same commit. Splitting them would mean a two-PR sequence where neither half tells a complete story.
The schema
One migration, no surprises:
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
Two integer-backed enums on the model, with prefixes so I don’t get name collisions later:
enum :medium_status, { not_imported: 0, draft: 1, published: 2 }, prefix: :medium
enum :linkedin_status, { not_posted: 0, posted: 1, failed: 2 }, prefix: :linkedin
The prefix: matters. Without it, Rails generates published? on Post for the Medium enum, and someday I’ll add a “published” concept that has nothing to do with Medium and the override will bite me. With prefix: :medium I get medium_published? and medium_draft? and the model says exactly what it means.
The filter trick
The controller takes filter values from query params. Naive Rails:
scope = scope.where(medium_status: params[:medium_status]) if params[:medium_status].present?
That works until someone (or a fuzzer, or me with a typo) sends ?medium_status=garbage. With integer-backed enums, Rails raises ArgumentError: 'garbage' is not a valid medium_status. With string-backed enums, you’d get an empty result and a confused user.
The fix is to allow-list against the enum’s known keys before passing to where:
scope = scope.where(medium_status: params[:medium_status]) if Post.medium_statuses.key?(params[:medium_status])
Post.medium_statuses returns the hash {"not_imported"=>0, "draft"=>1, "published"=>2}. key? returns true only for valid values. Bad input falls through silently and the user sees the unfiltered list, which is the right default for a permissive query string.
Same approach for the sort direction:
ALLOWED_SORTS = %w[asc desc].freeze
direction = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort].to_sym : :desc
order(pub_date: params[:sort]) would happily inject SQL if you let it. The allow-list is one line and stops the question from ever being asked.
The form that submits itself
Filters are select dropdowns. I want them to apply on change, not require a Submit button. The shortest path:
<select name="medium_status" onchange="this.form.submit()">
Rails 8 ships with Turbo on by default. form.submit() from JS still gets intercepted; Turbo Drive handles the navigation, the URL updates with the new params, and the page swaps without a full reload. No data attribute needed.
I almost reached for Stimulus for this. The whole controller would have been three lines that called event.target.form.requestSubmit(). The vanilla JS does the same thing in less code. Stimulus earns its keep when there’s actual behavior to encapsulate; “submit form on change” isn’t that.
Tests that exercise the URL surface
The controller specs don’t just check that the page renders. They check the order of titles in the response body to confirm the sort worked, and they check that filtering by blog produces only that blog’s posts:
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
Index-of in the body is crude. It works because the only place these titles appear is in the table cells, and it tells me what the user actually sees. A test that asserts on assigns(:posts) order would pass even if the view forgot to render them.
10 controller tests, 6 model tests, all green. Total suite: 70 runs, 195 assertions.
What’s next
This is the fourth feature commit. The app now has: auth, blog management, post scanning, post dashboard. The next layer is per-blog work: pick a Pexels image for a post, send it to Medium as a draft, watch for the publish, post to LinkedIn. Each one slots into the table I just built. They update enum values that the dashboard already knows how to display.
If I wanted to ship the v1 today, this dashboard plus a Medium “Import as draft” button would be the minimum useful thing. Two more issues’ worth of work, three at most. That’s a faster path to a working tool than I expected when I started this PRD.
The bar from the scanner still holds: 335 new lines for this issue, no new gems.
Related reading
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.
The editor commit button is a deploy button
Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.