Rails Action Text: Rijke Teksteditor, Aangepaste Bijlagen, PostgreSQL-zoeken en Valkuilen in Productie met Trix
Rails Action Text maakt rijke tekstediting mogelijk met Trix. Leer bijlagen, renderers, PostgreSQL-zoeken, N+1-oplossingen en productievalkuilen te vermijden.
Voordat Action Text in Rails 6 verscheen, besteedde ik drie dagen aan een rijke teksteditor voor een documentbeheersysteem bij een klant. Trix als standalone gem, een zelfgebouwde sanitizer, afbeeldingen base64-geëncodeerd inline opgeslagen — wat ik zes maanden later terugvond als gemiddeld 8 MB per rij in de database — een apart Paperclip-bijlagemodel voor de “echte” uploads, en een eigen renderer die het document probeerde te reconstrueren vanuit een opgeslagen JSON-blob. Het werkte. Maar het was ook een beveiligingsrisico dat we vier keer hebben gepatcht, een onderhoudsval die twee opvolgende engineers elk op hun eigen manier kapotmaakten, en iets waar ik nog steeds licht beschaamd naar kijk.
Rails Action Text biedt een compleet antwoord op dat alles. Ik draai het in productie sinds Rails 6.0 en het is een van de stillere succesverhalen van het Rails-ecosysteem: goed ontworpen, geleverd met echte tests, en geschikt voor 90% van wat producten in de praktijk nodig hebben van een rijke tekstveld. Hier is alles wat ik heb geleerd, inclusief de fouten die je maakt als niemand je van tevoren waarschuwt.
Wat Rails Action Text Eigenlijk Is
Trix is de editor — de JavaScript-component die in de browser draait. Action Text is de Rails-integratielaag bovenop Trix. Het verzorgt vier dingen:
Opslag. Een aparte tabel action_text_rich_texts slaat het Trix-document op als HTML, gekoppeld via een polymorf verband aan het bovenliggende record. Er is geen body-kolom op je posts-tabel.
Het Attachable-protocol. Elk ActiveRecord-model — of gewoon een Ruby-object — kan ActionText::Attachable implementeren om ingebed te kunnen worden in een Trix-document. Denk aan @-vermeldingen, productkaarten, ingesloten video-verwijzingen, alles wat je contentteam in een document wil slepen.
Server-side rendering. Wanneer je post.body rendert, lost Rails attachment-sgid-tokens op naar gerenderde HTML-partials. De editor slaat een compact token op; de view krijgt de echte UI.
Sanitisatie. Vóór het renderen stuurt Action Text de opgeslagen HTML door de Rails HTML-sanitizer met een instelbare lijst van toegestane tags. Scripts en inline event handlers worden automatisch gestript.
Het belangrijkste om te onthouden: Trix slaat zijn interne toestand op als JSON, maar de body-accessor van Action Text geeft een ActionText::RichText-object terug waarvan .to_s gesaniteerde HTML is, klaar voor render. Je hebt in applicatiecode nooit te maken met ruwe Trix-JSON.
Aan de Slag met Rails Action Text
Installeer Action Text:
bin/rails action_text:install
bin/rails db:migrate
Dit voegt een migratie voor action_text_rich_texts toe, koppelt JavaScript in en voegt een stylesheet-import toe. Declareer vervolgens rijke tekst op je model:
class Post < ApplicationRecord
has_rich_text :body
has_rich_text :summary
end
has_rich_text definieert een body-accessor. Er verandert niets aan de posts-tabel. In het formulier:
<%= form_with model: @post do |f| %>
<%= f.label :body %>
<%= f.rich_text_area :body %>
<% end %>
Geef het door in je controller:
def post_params
params.require(:post).permit(:title, :body, :summary)
end
Render het in een view:
<%= @post.body %>
Dat is de volledige basisopstelling. ActiveRecord verzorgt opslaan en laden; de body-accessor geeft het ActionText::RichText-object terug, dat als gesaniteerde HTML rendert.
Het action_text_rich_texts-schema
De schemastructuur kennen bespaart debugtijd. De tabel ziet er zo uit:
CREATE TABLE action_text_rich_texts (
id bigint PRIMARY KEY,
name varchar NOT NULL,
body text,
record_type varchar NOT NULL,
record_id bigint NOT NULL,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE UNIQUE INDEX ON action_text_rich_texts (record_type, record_id, name);
Elke has_rich_text :body op Post maakt een rij aan met name = "body", record_type = "Post", record_id = post.id. Een tweede has_rich_text :summary krijgt zijn eigen rij met name = "summary". Beide leven in dezelfde tabel.
De body-kolom slaat HTML op, niet ruwe Trix-JSON. Trix serialiseert intern en Action Text slaat de gesaniteerde HTML-representatie op. Attachment-tokens staan inline als <action-text-attachment sgid="...">.
Aangepaste Bijlagen: Het Attachable-protocol
Hier wordt Rails Action Text écht krachtig. Elk ActiveRecord-model kan insluitbaar worden door ActionText::Attachable te includen:
class Person < ApplicationRecord
include ActionText::Attachable
def to_trix_content_attachment_partial_path
"people/trix_content_attachment"
end
end
Maak de partial aan op app/views/people/_trix_content_attachment.html.erb:
<%= link_to "@#{person.name}", person_path(person), class: "mention text-indigo-600 font-medium" %>
Dat is de volledige @-vermelding-implementatie. De Trix-editor biedt een zoekinterface voor insluitbare objecten — koppel die aan een Attachables-controller:
class AttachablesController < ApplicationController
def index
@people = Person.where("name ILIKE ?", "%#{params[:query]}%").limit(10)
render json: @people.map { |p|
{ sgid: p.attachable_sgid, content: p.name, description: p.email }
}
end
end
Wanneer een gebruiker een resultaat selecteert, slaat Trix het sgid op. Bij het renderen lost Action Text het sgid op, roept to_trix_content_attachment_partial_path aan en vervangt het token door je gerenderde partial — server-side, bij elk verzoek.
Je kunt elke Ruby-klasse insluitbaar maken, niet alleen ActiveRecord. Ze moet reageren op attachable_sgid (wat GlobalID::Identification biedt) en een oplosbaar partial-pad hebben. Een klein value object:
class StatusBadge
include GlobalID::Identification
attr_reader :id, :label, :color
def self.find(id)
STATUSES.fetch(id) { raise ActiveRecord::RecordNotFound }
end
def initialize(id, label, color)
@id = id
@label = label
@color = color
end
def to_trix_content_attachment_partial_path
"status_badges/trix_content_attachment"
end
STATUSES = {
"critical" => new("critical", "KRITIEK", "red"),
"warning" => new("warning", "WAARSCHUWING", "amber"),
"ok" => new("ok", "OK", "green")
}.freeze
end
De partial rendert het label; Action Text lost het sgid op bij elke paginaweergave. Geen database-joins, geen opslag buiten de tokenstring.
Rendercontexten: In de Editor vs. Gepubliceerde View
Action Text rendert bijlagen in twee contexten. :in_editor is de real-time voorvertoning in de Trix-editor terwijl je typt. De gerenderde view (standaard) is wat bezoekers na opslaan zien.
Je kunt binnen één partial vertakken op context:
<%# app/views/people/_trix_content_attachment.html.erb %>
<% if options[:in_editor] %>
<span class="mention-chip bg-indigo-100 text-indigo-800 px-2 py-0.5 rounded text-sm">
@<%= person.name %>
</span>
<% else %>
<%= link_to "@#{person.name}", person_path(person), class: "mention-link" %>
<% end %>
Zo houd je de editorervaring overzichtelijk en behoud je volledige controle over de gepubliceerde weergave.
Content-sanitisatie in Rails Action Text
Action Text gebruikt de Rails HTML-sanitizer met een standaard lijst van toegestane tags. Scripts en inline event handlers worden automatisch gestript. Stel de configuratie in voor jouw app:
# config/initializers/action_text.rb
ActionText::ContentHelper.sanitizer = Rails::HTML5::SafeListSanitizer.new
ActionText::ContentHelper.allowed_tags = %w[
div p br blockquote h1 h2 h3 h4 h5 h6
ul ol li strong em del a
figure figcaption
action-text-attachment
]
ActionText::ContentHelper.allowed_attributes = %w[
href target rel class id
sgid content-type filename filesize previewable url caption width height
]
Twee regels die je pijn besparen: houd altijd action-text-attachment in de toegestane tags en altijd sgid in de toegestane attributen. Verwijder je een van beide, dan verdwijnen alle ingesloten bijlagen in elk document stil — geen foutmelding, gewoon weg.
Als je content van externe bronnen verwerkt (importtaken, API-endpoints, webhooks), saniteer dan vóór toewijzing:
safe_html = ActionText::ContentHelper.sanitize(raw_html_from_api)
post.body = ActionText::Content.new(safe_html)
post.save!
Wijs nooit niet-vertrouwde HTML direct toe. De editor dwingt client-side sanitisatie af, maar het model-toewijzingspad herontsanteert in oudere Rails-versies niet automatisch bij opslaan.
Zoeken in Rails Action Text-inhoud met PostgreSQL
De body-kolom in action_text_rich_texts slaat HTML op. Voor PostgreSQL-zoekopdrachten strip je de tags en indexeer je de tekstinhoud. Gecombineerd met de pg_search-gem ziet de aanpak er zo uit:
class AddSearchIndexToActionTextRichTexts < ActiveRecord::Migration[7.1]
def up
execute <<~SQL
CREATE INDEX action_text_rich_texts_body_search_idx
ON action_text_rich_texts
USING gin(
to_tsvector('dutch',
coalesce(regexp_replace(body, '<[^>]*>', ' ', 'g'), '')
)
)
WHERE record_type = 'Post' AND name = 'body';
SQL
end
def down
execute "DROP INDEX IF EXISTS action_text_rich_texts_body_search_idx;"
end
end
Voeg een zoekscope toe aan het model:
class Post < ApplicationRecord
has_rich_text :body
scope :search_body, ->(query) {
joins(
"INNER JOIN action_text_rich_texts ON " \
"action_text_rich_texts.record_type = 'Post' AND " \
"action_text_rich_texts.record_id = posts.id AND " \
"action_text_rich_texts.name = 'body'"
).where(
"to_tsvector('dutch', coalesce(regexp_replace(action_text_rich_texts.body, '<[^>]*>', ' ', 'g'), '')) " \
"@@ plainto_tsquery('dutch', ?)",
query
)
}
end
Gebruik:
Post.search_body("Rails performance").with_rich_text_body_and_embeds
Koppel .with_rich_text_body_and_embeds na de zoekscope — anders triggert renderen een N+1, wat direct naar het volgende onderwerp leidt.
N+1-queries: De Meest Voorkomende Rails Action Text-fout in Productie
Na negentien jaar Rails voorspel ik met grote zekerheid de eerste prestatiefout die elk team maakt na het uitrollen van Rails Action Text: vergeten eager te laden. Elke has_rich_text-accessor triggert een aparte query naar action_text_rich_texts als je niet expliciet laadt.
# Dit genereert N+1 — één extra query per post
Post.limit(20).each { |post| puts post.body }
Action Text biedt scopes hiervoor. Gebruik ze:
# Laadt het rijke-tekst-record eager
Post.with_rich_text_body.limit(20)
# Laadt het rijke-tekst-record EN alle ingesloten Active Storage-blobs eager
Post.with_rich_text_body_and_embeds.limit(20)
# Laadt alle has_rich_text-attributen tegelijk eager
Post.with_all_rich_text.limit(20)
Gebruik in productie standaard altijd _and_embeds. Als een body afbeeldingsbijlagen bevat en je gebruikt gewone with_rich_text_body, worden de bijlage-blobs alsnog per stuk geladen tijdens het renderen. Het verschil bij een lijstweergave van twintig posts met vijf ingesloten afbeeldingen elk: 100 queries versus 3.
Voor aangepaste insluitbare modellen laadt Action Text ze niet automatisch voor. Als je Person-records insluít via @-vermeldingen, wordt elk afzonderlijk opgehaald tijdens het renderen. Laad ze zelf voor:
posts = Post.with_rich_text_body_and_embeds.limit(20)
person_ids = posts.flat_map { |post|
post.body.attachments.filter_map { |attachment|
attachment.attachable.id if attachment.attachable.is_a?(Person)
}
}
Person.where(id: person_ids).load
Dit laadt in de ActiveRecord identity map. Wanneer Action Text elke attachment-partial rendert, geeft Person.find(id) het al geladen object terug zonder databaseronde.
Als je applicatie veel modellen met rijke tekst en complexe insluitbare structuren heeft, neemt een leesreplica de querydruk van je primaire database — maar het N+1-probleem oplossen is altijd de eerste stap.
Rails Action Text Testen
In systeemtests werk je via de .trix-content-selector:
class PostsSystemTest < ApplicationSystemTestCase
test "maakt een post met rijke body" do
visit new_post_path
fill_in "Titel", with: "Productiepatronen"
within(".trix-content") { find("div").click.send_keys("Rails is geweldig") }
click_on "Publiceren"
assert_selector "h1", text: "Productiepatronen"
assert_text "Rails is geweldig"
end
end
Voor integratie- en unittests wijs je rijke tekst toe als gewone HTML-string:
test "post body slaat html-inhoud op" do
post = Post.create!(
title: "Test",
body: "<p>Hallo <strong>wereld</strong></p>"
)
assert post.body.to_s.include?("Hallo")
assert post.body.to_s.include?("<strong>wereld</strong>")
end
Voor insluitbare objecten in tests maak je eerst het object aan en sluít je het sgid handmatig in:
test "body rendert ingesloten personenvermeldingen" do
alice = people(:alice)
post = Post.create!(
title: "Teamupdate",
body: ActionText::Content.new(
%(<action-text-attachment sgid="#{alice.attachable_sgid}"></action-text-attachment>)
)
)
rendered = render_rich_text(post.body)
assert_includes rendered, alice.name
end
Productievalkuilen in Rails Action Text
Verweesde Blobs na Verwijdering
Active Storage-blobs die via Trix zijn ingesloten worden opgeschoond wanneer een ActionText::RichText-record wordt vernietigd — maar de blobs in je S3-bucket blijven staan. Draai een geplande taak:
# Verwijder niet-gekoppelde blobs ouder dan 2 dagen (veiligheidsmarge voor lopende uploads)
ActiveStorage::Blob.unattached
.where("active_storage_blobs.created_at < ?", 2.days.ago)
.find_each(&:purge_later)
Koppel dit aan je taakplanner — Solid Queue recurring jobs of een cron-taak — en draai het nachtelijks.
Rijke-tekst-attributen Hernoemen
Als je has_rich_text :body hernoemt naar has_rich_text :content en vergeet de action_text_rich_texts-rijen te migreren, staat bestaande data nog steeds onder name = "body". De nieuwe accessor vindt niets. Schrijf een migratie:
class RenameActionTextBodyToContent < ActiveRecord::Migration[7.1]
def up
ActionText::RichText
.where(record_type: "Post", name: "body")
.update_all(name: "content")
end
def down
ActionText::RichText
.where(record_type: "Post", name: "content")
.update_all(name: "body")
end
end
Dit is het soort ding dat je mist als je action_text_rich_texts als onzichtbare infrastructuur behandelt. Het is geen onzichtbare infrastructuur — het is een echte tabel die meedoet aan datamigaties.
Tabelgrootte in Drukbezochte Applicaties
Door Trix gegenereerde HTML is uitgebreid. Een enkele alinea met een kop en dikgedrukte tekst produceert opmaak als:
<h2>Deployment</h2><div>Zorg dat je <strong>migraties</strong> eerst uitvoert.</div><br>
In een systeem met 500K posts en bodies van gemiddeld 4 KB groeit de tabel action_text_rich_texts snel richting 2 GB. De unieke index op (record_type, record_id, name) is goed voor puntopzoekingen, maar ongeïndexeerde LIKE-zoekopdrachten op body zijn funest. Voeg de GIN-index toe zoals hierboven beschreven; val nooit terug op LIKE '%zoekterm%'.
Contentmigratie van Oudere Systemen
Als je migreert van een systeem dat HTML in een gewone tekstkolom opsloeg, seed action_text_rich_texts direct:
Post.find_each do |post|
next if post.legacy_body.blank?
ActionText::RichText.create!(
record: post,
name: "body",
body: post.legacy_body
)
end
Draai dit in een achtergrondtaak, niet inline. Gebruik voor grote tabellen find_in_batches en insert_all voor betere prestaties.
Wanneer Alternatieven Overwegen
Rails Action Text met Trix is de juiste standaard voor de overgrote meerderheid van Rails-applicaties die rijke tekst nodig hebben. Wanneer elders kijken:
Collaboratief real-time bewerken. Trix is een single-user editor. Voor Google Docs-achtig gelijktijdig bewerken met operational transforms of CRDTs, kijk naar Tiptap met Yjs over Action Cable. Trix ondersteunt dit nooit.
Complexe documentstructuur. Trix ondersteunt een afgebakende subset van HTML: alinea’s, koppen, lijsten, citaten, vet, cursief, links, bijlagen. Tabellen, kolomindelingen, voetnoten, geneste structuren — dit past niet in het Trix-model. Op ProseMirror gebaseerde editors (Tiptap, Quill) hanteren dit wel, maar je verliest de Action Text bijlagepipeline en server-side rendering.
API-first applicaties. Als een React- of mobiele client zijn eigen UI rendert, is server-side bijlage-rendering minder nuttig. Sla dan draagbare content op (ProseMirror JSON, Markdown) in een gewone tekstkolom en laat de client het renderen.
Voor teamwiki’s, blogplatformen, CMS-velden, e-mailcompositors, documentatietools, adminnotities, supportticket-inhoud — Action Text met Trix is productie-bewezen en saai op de beste manier. Saai is precies wat je wil in een rijke-tekst-stack.
Veelgestelde Vragen
Hoe slaat Rails Action Text inhoud op in de database?
Action Text voegt geen kolom toe aan je modeltabel. Het gebruikt een aparte tabel action_text_rich_texts met een polymorf verband. Elke has_rich_text-declaratie maakt rijen aan met record_type, record_id en name. De body-kolom slaat gesaniteerde HTML op — niet de ruwe Trix-JSON.
Hoe zoek ik in Rails Action Text-inhoud met PostgreSQL?
Join naar action_text_rich_texts, strip HTML-tags met regexp_replace(body, '<[^>]*>', ' ', 'g') en voer een PostgreSQL-zoekvolledige-tekst-query uit met to_tsvector en plainto_tsquery. Voeg een GIN-index toe op de tsvector-expressie gefilterd op record_type en name voor snelle zoekopdrachten. Vermijd LIKE '%zoekterm%' — dat gebruikt geen enkele index.
Hoe voorkom ik N+1-queries met Rails Action Text?
Gebruik Post.with_rich_text_body_and_embeds (niet kale Post.all) wanneer je een lijst van records met rijke-tekst-bodies wil renderen. De _and_embeds-variant laadt zowel het ActionText::RichText-record als alle Active Storage-blobs voor ingesloten afbeeldingen. Voor ingesloten insluitbare objecten zoals @-vermelde gebruikers verzamel je hun ids uit de geladen bodies en laad je ze handmatig voor met één WHERE IN-query.
Kan ik Rails Action Text gebruiken in API-only-modus?
Je kunt has_rich_text aanroepen in een API-only app en inhoud opslaan via het model, maar de Trix-editor en server-side partial-rendering zijn view-laagzaken die niet van toepassing zijn. body.to_s geeft gesaniteerde HTML die je via JSON kunt blootstellen. In de praktijk slaan de meeste API-only apps Markdown of ProseMirror JSON op in een gewone tekstkolom en renderen ze op de client, wat eenvoudiger is voor die context.
Hulp nodig bij het integreren van rijke tekst, contentzoeken of documentworkflows in je Rails-applicatie? TTB Software is gespecialiseerd in Rails-architectuur voor producten die het de eerste keer goed willen doen. Negentien jaar Rails betekent dat we weten waar Action Text uitblinkt en waar het tekortschiet.
Related Articles
Rails PostgreSQL Exclusion Constraints: Voorkom Dubbele Boekingen met tsrange en btree_gist
Rails PostgreSQL exclusion constraints stoppen dubbele boekingen op databaselaag. Gebruik tsrange, btree_gist en Rail...
Ledenboek bouwen: verenigingsbestuur vastleggen in Rails
Hoe we een ledenadministratieplatform voor Nederlandse verenigingen bouwden, en waarom het moeilijke deel niet de CRU...
Rails PgHero: Postgres Health Dashboard voor Trage Queries, Ontbrekende Indexen en Ruimtebewaking
Rails PgHero installatiegids: mount het Postgres health dashboard, vind trage queries met pg_stat_statements, krijg i...