Views & Helpers¶
The ragdoll-rails gem provides a comprehensive set of view templates, components, and helper methods to quickly build document management and search interfaces in your Rails application.
View Templates¶
Document Views¶
Document Index (documents/index.html.erb
)¶
<div class="ragdoll-documents-index">
<div class="documents-header">
<h1>Documents</h1>
<div class="documents-actions">
<%= link_to "Upload Document", new_document_path, class: "btn btn-primary" %>
<%= link_to "Bulk Upload", bulk_upload_documents_path, class: "btn btn-secondary" %>
</div>
</div>
<div class="documents-filters">
<%= form_with url: documents_path, method: :get, local: true, class: "filter-form" do |form| %>
<div class="filter-group">
<%= form.select :status, options_for_select([
['All Statuses', ''],
['Processed', 'processed'],
['Processing', 'processing'],
['Pending', 'pending'],
['Failed', 'failed']
], params[:status]), {}, { class: "form-select" } %>
</div>
<div class="filter-group">
<%= form.select :content_type, content_type_options, {}, { class: "form-select" } %>
</div>
<div class="filter-group">
<%= form.submit "Filter", class: "btn btn-outline-primary" %>
</div>
<% end %>
</div>
<div class="documents-grid">
<% @documents.each do |document| %>
<%= render 'document_card', document: document %>
<% end %>
</div>
<div class="documents-pagination">
<%= paginate @documents %>
</div>
</div>
Document Card (documents/_document_card.html.erb
)¶
<div class="document-card" data-document-id="<%= document.id %>">
<div class="document-thumbnail">
<% if document.file.attached? && document.file.representable? %>
<%= image_tag document.file.representation(resize_to_limit: [200, 150]),
alt: document.title, class: "thumbnail-image" %>
<% else %>
<div class="file-type-icon">
<%= ragdoll_file_icon(document.content_type) %>
</div>
<% end %>
</div>
<div class="document-content">
<h3 class="document-title">
<%= link_to document.title, document_path(document) %>
</h3>
<p class="document-description">
<%= truncate(document.description, length: 100) %>
</p>
<div class="document-meta">
<span class="file-size"><%= human_file_size(document.file_size) %></span>
<span class="upload-date"><%= time_ago_in_words(document.created_at) %> ago</span>
<span class="status-badge status-<%= document.status %>">
<%= document.status.humanize %>
</span>
</div>
<div class="document-actions">
<%= link_to "View", document_path(document), class: "btn btn-sm btn-outline-primary" %>
<% if can_edit_document?(document) %>
<%= link_to "Edit", edit_document_path(document), class: "btn btn-sm btn-outline-secondary" %>
<% end %>
<% if can_delete_document?(document) %>
<%= link_to "Delete", document_path(document), method: :delete,
confirm: "Are you sure?", class: "btn btn-sm btn-outline-danger" %>
<% end %>
</div>
</div>
</div>
Document Show (documents/show.html.erb
)¶
<div class="ragdoll-document-show">
<div class="document-header">
<div class="document-info">
<h1><%= @document.title %></h1>
<p class="document-description"><%= @document.description %></p>
<div class="document-metadata">
<div class="meta-item">
<strong>File Type:</strong> <%= @document.content_type %>
</div>
<div class="meta-item">
<strong>File Size:</strong> <%= human_file_size(@document.file_size) %>
</div>
<div class="meta-item">
<strong>Status:</strong>
<span class="status-badge status-<%= @document.status %>">
<%= @document.status.humanize %>
</span>
</div>
<div class="meta-item">
<strong>Uploaded:</strong> <%= @document.created_at.strftime("%B %d, %Y at %I:%M %p") %>
</div>
<% if @document.user %>
<div class="meta-item">
<strong>Uploaded by:</strong> <%= @document.user.name %>
</div>
<% end %>
</div>
</div>
<div class="document-actions">
<%= link_to "Download", document_download_path(@document),
class: "btn btn-primary" %>
<% if can_edit_document?(@document) %>
<%= link_to "Edit", edit_document_path(@document),
class: "btn btn-secondary" %>
<% end %>
<% if can_reprocess_document?(@document) %>
<%= link_to "Reprocess", reprocess_document_path(@document),
method: :post, class: "btn btn-outline-secondary" %>
<% end %>
</div>
</div>
<div class="document-content">
<div class="content-preview">
<h3>Content Preview</h3>
<div class="preview-text">
<%= simple_format(@content_preview) %>
</div>
</div>
<% if @document.metadata.any? %>
<div class="document-custom-metadata">
<h3>Metadata</h3>
<dl class="metadata-list">
<% @document.metadata.each do |key, value| %>
<dt><%= key.humanize %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
</div>
<% end %>
</div>
<% if @related_documents.any? %>
<div class="related-documents">
<h3>Related Documents</h3>
<div class="related-grid">
<% @related_documents.each do |doc| %>
<%= render 'document_card', document: doc %>
<% end %>
</div>
</div>
<% end %>
</div>
Search Views¶
Search Interface (search/index.html.erb
)¶
<div class="ragdoll-search">
<div class="search-header">
<h1>Search Documents</h1>
</div>
<div class="search-form">
<%= form_with url: search_path, method: :get, local: true, class: "search-form-wrapper" do |form| %>
<div class="search-input-group">
<%= form.text_field :q, value: params[:q],
placeholder: "Search documents...",
class: "search-input",
data: {
action: "input->search#suggest",
search_target: "input"
} %>
<%= form.submit "Search", class: "search-btn" %>
</div>
<div class="search-options">
<%= form.select :search_type, [
['Hybrid Search', 'hybrid'],
['Semantic Search', 'semantic'],
['Keyword Search', 'keyword']
], { selected: params[:search_type] || 'hybrid' }, { class: "search-type-select" } %>
<%= form.check_box :include_facets, checked: params[:include_facets] %>
<%= form.label :include_facets, "Show filters" %>
</div>
<% end %>
</div>
<div class="search-suggestions" data-search-target="suggestions" style="display: none;">
<!-- Populated via Stimulus -->
</div>
<div class="search-results-container">
<% if @results.present? %>
<div class="search-meta">
<p>Found <%= pluralize(@results.total_count, 'result') %>
<% if params[:q].present? %>for "<%= params[:q] %>"<% end %>
in <%= number_with_precision(@search_service.search_time, precision: 3) %>s
</p>
</div>
<div class="search-content">
<% if @facets.present? %>
<div class="search-facets">
<%= render 'search_facets', facets: @facets %>
</div>
<% end %>
<div class="search-results">
<% @results.each do |result| %>
<%= render 'search_result', result: result %>
<% end %>
<div class="search-pagination">
<%= paginate @results %>
</div>
</div>
</div>
<% elsif params[:q].present? %>
<div class="no-results">
<h3>No results found</h3>
<p>Try adjusting your search terms or filters.</p>
<% if @suggestions.present? %>
<div class="search-suggestions">
<p>Did you mean:</p>
<ul>
<% @suggestions.each do |suggestion| %>
<li>
<%= link_to suggestion, search_path(q: suggestion) %>
</li>
<% end %>
</ul>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
Search Result (search/_search_result.html.erb
)¶
<div class="search-result" data-result-id="<%= result.id %>">
<div class="result-header">
<h3 class="result-title">
<%= link_to result.title, document_path(result) %>
</h3>
<div class="result-meta">
<span class="similarity-score">
<%= number_to_percentage(result.similarity_score * 100, precision: 1) %> match
</span>
<span class="file-type"><%= result.content_type %></span>
<span class="upload-date"><%= result.created_at.strftime("%b %d, %Y") %></span>
</div>
</div>
<div class="result-content">
<p class="result-description"><%= result.description %></p>
<div class="result-preview">
<%= highlight(result.content_preview, params[:q]) %>
</div>
</div>
<div class="result-actions">
<%= link_to "View Document", document_path(result), class: "btn btn-sm btn-primary" %>
<%= link_to "Download", document_download_path(result), class: "btn btn-sm btn-outline-secondary" %>
</div>
</div>
View Helpers¶
Ragdoll::ApplicationHelper¶
module Ragdoll::ApplicationHelper
# File type and size helpers
def ragdoll_file_icon(content_type)
icon_class = case content_type
when /^image\// then "bi-file-earmark-image"
when /pdf/ then "bi-file-earmark-pdf"
when /word|doc/ then "bi-file-earmark-word"
when /excel|sheet/ then "bi-file-earmark-excel"
when /powerpoint|presentation/ then "bi-file-earmark-ppt"
when /^text\// then "bi-file-earmark-text"
when /^audio\// then "bi-file-earmark-music"
when /^video\// then "bi-file-earmark-play"
else "bi-file-earmark"
end
content_tag :i, "", class: "#{icon_class} file-icon"
end
def human_file_size(size)
return "0 bytes" if size.nil? || size.zero?
number_to_human_size(size, precision: 1)
end
def content_type_options
types = Ragdoll::Document.distinct.pluck(:content_type).compact.sort
options = [['All Types', '']]
types.each do |type|
display_name = case type
when /^image\// then "Images"
when /pdf/ then "PDFs"
when /word|doc/ then "Word Documents"
when /excel|sheet/ then "Spreadsheets"
when /powerpoint|presentation/ then "Presentations"
when /^text\// then "Text Files"
else type.humanize
end
options << [display_name, type]
end
options_for_select(options.uniq, params[:content_type])
end
# Status helpers
def status_badge(status)
css_class = case status
when 'processed' then 'badge-success'
when 'processing' then 'badge-warning'
when 'pending' then 'badge-secondary'
when 'failed' then 'badge-danger'
else 'badge-light'
end
content_tag :span, status.humanize, class: "badge #{css_class}"
end
def processing_progress(document)
return unless document.processing?
progress = document.processing_progress || 0
content_tag :div, class: "progress" do
content_tag :div, "",
class: "progress-bar",
style: "width: #{progress}%",
data: {
document_id: document.id,
progress: progress
}
end
end
# Authorization helpers
def can_edit_document?(document)
return false unless current_user
Ragdoll.configuration.authorize_document_access&.call(document, current_user) ||
document.user == current_user ||
current_user.admin?
end
def can_delete_document?(document)
can_edit_document?(document)
end
def can_reprocess_document?(document)
can_edit_document?(document) && document.processed?
end
# Search helpers
def highlight_search_terms(text, query)
return text if query.blank?
terms = query.split(/\s+/).reject(&:blank?)
highlighted = text
terms.each do |term|
highlighted = highlighted.gsub(
/#{Regexp.escape(term)}/i,
'<mark>\0</mark>'
)
end
highlighted.html_safe
end
def search_result_snippet(content, query, length: 200)
return truncate(content, length: length) if query.blank?
# Find the best snippet containing search terms
terms = query.split(/\s+/).reject(&:blank?)
term_positions = []
terms.each do |term|
pos = content.downcase.index(term.downcase)
term_positions << pos if pos
end
if term_positions.any?
start_pos = [term_positions.min - 50, 0].max
snippet = content[start_pos, length]
"..." + snippet + "..."
else
truncate(content, length: length)
end
end
# Metadata helpers
def format_metadata_value(value)
case value
when Date, DateTime, Time
value.strftime("%B %d, %Y")
when TrueClass, FalseClass
value ? "Yes" : "No"
when Array
value.join(", ")
when Hash
value.to_json
else
value.to_s
end
end
def metadata_icon(key)
icon_class = case key.to_s.downcase
when /author|creator|owner/ then "bi-person"
when /date|time|created|updated/ then "bi-calendar"
when /category|tag|type/ then "bi-tag"
when /size|length|count/ then "bi-rulers"
when /location|place|geo/ then "bi-geo-alt"
when /url|link|website/ then "bi-link"
when /email|mail/ then "bi-envelope"
when /phone|tel/ then "bi-telephone"
else "bi-info-circle"
end
content_tag :i, "", class: "#{icon_class} metadata-icon"
end
end
Ragdoll::SearchHelper¶
module Ragdoll::SearchHelper
def search_form(options = {})
defaults = {
url: search_path,
method: :get,
local: true,
class: "ragdoll-search-form"
}
form_with **defaults.merge(options) do |form|
render 'ragdoll/search/search_form', form: form
end
end
def search_filters(facets)
return unless facets.present?
content_tag :div, class: "search-filters" do
facets.map do |facet_name, facet_data|
render 'ragdoll/search/filter_group',
facet_name: facet_name,
facet_data: facet_data
end.join.html_safe
end
end
def similarity_score_badge(score)
percentage = (score * 100).round(1)
css_class = case percentage
when 90..100 then 'badge-success'
when 70..89 then 'badge-warning'
when 50..69 then 'badge-secondary'
else 'badge-light'
end
content_tag :span, "#{percentage}%", class: "badge #{css_class}"
end
def search_time_display(time_in_seconds)
if time_in_seconds < 1
"#{(time_in_seconds * 1000).round}ms"
else
"#{time_in_seconds.round(2)}s"
end
end
def search_suggestions(suggestions)
return unless suggestions.present?
content_tag :div, class: "search-suggestions" do
content_tag :p, "Did you mean:" do
suggestions.map do |suggestion|
link_to suggestion, search_path(q: suggestion), class: "suggestion-link"
end.join(" · ").html_safe
end
end
end
def facet_filter_link(facet_name, facet_value, count)
current_filters = params[:filters] || {}
new_filters = current_filters.dup
if new_filters[facet_name] == facet_value
new_filters.delete(facet_name)
css_class = "facet-link active"
else
new_filters[facet_name] = facet_value
css_class = "facet-link"
end
link_to search_path(q: params[:q], filters: new_filters), class: css_class do
"#{facet_value} (#{count})"
end
end
end
Stimulus Controllers¶
Search Controller (search_controller.js
)¶
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "suggestions", "results"]
static values = {
suggestionsUrl: String,
debounceDelay: { type: Number, default: 300 }
}
connect() {
this.timeout = null
}
suggest() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
const query = this.inputTarget.value.trim()
if (query.length > 2) {
this.fetchSuggestions(query)
} else {
this.hideSuggestions()
}
}, this.debounceDelayValue)
}
async fetchSuggestions(query) {
try {
const response = await fetch(`${this.suggestionsUrlValue}?q=${encodeURIComponent(query)}`)
const suggestions = await response.json()
this.displaySuggestions(suggestions)
} catch (error) {
console.error('Failed to fetch suggestions:', error)
}
}
displaySuggestions(suggestions) {
if (suggestions.length === 0) {
this.hideSuggestions()
return
}
const suggestionsList = suggestions.map(suggestion =>
`<li class="suggestion-item" data-action="click->search#selectSuggestion">${suggestion}</li>`
).join('')
this.suggestionsTarget.innerHTML = `<ul class="suggestions-list">${suggestionsList}</ul>`
this.suggestionsTarget.style.display = 'block'
}
hideSuggestions() {
this.suggestionsTarget.style.display = 'none'
}
selectSuggestion(event) {
const suggestion = event.target.textContent
this.inputTarget.value = suggestion
this.hideSuggestions()
this.submitSearch()
}
submitSearch() {
this.element.querySelector('form').submit()
}
disconnect() {
clearTimeout(this.timeout)
}
}
Upload Controller (upload_controller.js
)¶
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "preview", "progress", "errors"]
static values = {
maxFileSize: Number,
allowedTypes: Array,
uploadUrl: String
}
preview() {
const files = this.inputTarget.files
this.clearErrors()
if (files.length === 0) {
this.clearPreview()
return
}
this.validateFiles(files)
this.displayPreview(files)
}
validateFiles(files) {
let hasErrors = false
Array.from(files).forEach(file => {
if (file.size > this.maxFileSizeValue) {
this.showError(`File "${file.name}" is too large. Maximum size is ${this.formatFileSize(this.maxFileSizeValue)}.`)
hasErrors = true
}
if (!this.allowedTypesValue.includes(file.type)) {
this.showError(`File type "${file.type}" is not allowed for "${file.name}".`)
hasErrors = true
}
})
return !hasErrors
}
displayPreview(files) {
const previews = Array.from(files).map(file => {
return `
<div class="file-preview" data-filename="${file.name}">
<div class="file-info">
<span class="filename">${file.name}</span>
<span class="filesize">${this.formatFileSize(file.size)}</span>
</div>
<div class="file-actions">
<button type="button" class="remove-file" data-action="click->upload#removeFile">×</button>
</div>
</div>
`
}).join('')
this.previewTarget.innerHTML = previews
}
removeFile(event) {
const preview = event.target.closest('.file-preview')
const filename = preview.dataset.filename
// Remove from file input (requires recreating the input)
const dt = new DataTransfer()
const files = this.inputTarget.files
Array.from(files).forEach(file => {
if (file.name !== filename) {
dt.items.add(file)
}
})
this.inputTarget.files = dt.files
preview.remove()
if (this.inputTarget.files.length === 0) {
this.clearPreview()
}
}
async upload(event) {
event.preventDefault()
const files = this.inputTarget.files
if (files.length === 0) return
if (!this.validateFiles(files)) return
this.showProgress()
try {
for (let i = 0; i < files.length; i++) {
await this.uploadFile(files[i], i + 1, files.length)
}
this.onUploadSuccess()
} catch (error) {
this.showError('Upload failed: ' + error.message)
} finally {
this.hideProgress()
}
}
async uploadFile(file, index, total) {
const formData = new FormData()
formData.append('document[file]', file)
formData.append('document[title]', file.name)
const response = await fetch(this.uploadUrlValue, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
}
})
if (!response.ok) {
throw new Error(`Failed to upload ${file.name}`)
}
this.updateProgress(index, total)
}
showProgress() {
this.progressTarget.style.display = 'block'
this.updateProgress(0, 1)
}
updateProgress(current, total) {
const percentage = (current / total) * 100
const progressBar = this.progressTarget.querySelector('.progress-bar')
if (progressBar) {
progressBar.style.width = `${percentage}%`
progressBar.textContent = `${current} / ${total} files`
}
}
hideProgress() {
this.progressTarget.style.display = 'none'
}
onUploadSuccess() {
this.clearPreview()
this.inputTarget.value = ''
// Optionally redirect or reload
window.location.reload()
}
showError(message) {
const errorDiv = document.createElement('div')
errorDiv.className = 'alert alert-danger'
errorDiv.textContent = message
this.errorsTarget.appendChild(errorDiv)
}
clearErrors() {
this.errorsTarget.innerHTML = ''
}
clearPreview() {
this.previewTarget.innerHTML = ''
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}
View Components¶
Document Card Component¶
# app/components/ragdoll/document_card_component.rb
class Ragdoll::DocumentCardComponent < ViewComponent::Base
def initialize(document:, show_actions: true, size: :medium)
@document = document
@show_actions = show_actions
@size = size
end
private
attr_reader :document, :show_actions, :size
def card_classes
base_classes = ["ragdoll-document-card"]
base_classes << "card-#{size}"
base_classes << "card-#{document.status}"
base_classes.join(" ")
end
def thumbnail_url
if document.file.attached? && document.file.representable?
rails_representation_url(document.file.representation(resize_to_limit: thumbnail_size))
else
nil
end
end
def thumbnail_size
case size
when :small then [100, 75]
when :large then [400, 300]
else [200, 150]
end
end
end
<!-- app/components/ragdoll/document_card_component.html.erb -->
<div class="<%= card_classes %>" data-document-id="<%= document.id %>">
<div class="card-thumbnail">
<% if thumbnail_url %>
<%= image_tag thumbnail_url, alt: document.title, class: "thumbnail-image" %>
<% else %>
<div class="file-type-icon">
<%= ragdoll_file_icon(document.content_type) %>
</div>
<% end %>
<div class="card-overlay">
<%= status_badge(document.status) %>
</div>
</div>
<div class="card-content">
<h3 class="card-title">
<%= link_to document.title, document_path(document) %>
</h3>
<% if document.description.present? %>
<p class="card-description">
<%= truncate(document.description, length: description_length) %>
</p>
<% end %>
<div class="card-meta">
<span class="file-size"><%= human_file_size(document.file_size) %></span>
<span class="upload-date"><%= time_ago_in_words(document.created_at) %> ago</span>
</div>
<% if show_actions %>
<div class="card-actions">
<%= link_to "View", document_path(document), class: "btn btn-sm btn-primary" %>
<% if can_edit_document?(document) %>
<%= link_to "Edit", edit_document_path(document), class: "btn btn-sm btn-secondary" %>
<% end %>
</div>
<% end %>
</div>
</div>
This comprehensive view and helper documentation provides everything you need to customize and extend the Ragdoll Rails UI components for your specific application needs.