rails new rails-microlink-example -d=postgresql --webpacker=stimulus
rails db:create
rails db:migrate
rails g scaffold link url:string
- Prevent null values at the database level.
class CreateLinks < ActiveRecord::Migration[6.1]
def change
create_table :links do |t|
t.string :url, null: false
t.timestamps
end
end
end
rails db:migrate
- Add validations.
class Link < ApplicationRecord
validates :url, presence: true
end
rails add_meta_data_to_link meta_data:jsonb
rails db:migrate
- Use ActiveRecord::Store to serialize data saved to the
meta_data
column. We can call these accessors anything we want to, but we'll use the sames names that at returned from the Microlink response
class Link < ApplicationRecord
store :meta_data, accessors: [ :description, :image, :title ], coder: JSON
validates :url, presence: true
end
We could create a separate column for each
accessor
, but usingActiveRecord::Store
allows for greater flexibly and keeps the database simple.
- Update
link_params
to include values from themeta_data
column.
class LinksController < ApplicationController
...
private
...
def link_params
params.require(:link).permit(:url, :description, :image, :title)
end
end
yarn add @microlink/mql
touch app/javascript/controllers/microlink_controller.js
import { Controller } from "stimulus"
import mql from "@microlink/mql"
export default class extends Controller {
}
If you were you run
rails s
and view the console, you would see the following error:
- Update
babel.config.js
by addingsourceType: "unambiguous"
.
module.exports = function(api) {
...
return {
sourceType: "unambiguous",
presets: [
...
].filter(Boolean),
...
}
}
I found this solution be searching for the error and came across these resources:
Step 5: Save API Response to Hidden Fields
- Update the markup in
app/views/links/_form.html.erb
.
<%= form_with(model: link, data: { controller: "microlink" }) do |form| %>
...
<div class="field">
<%= form.label :url %>
<%= form.url_field :url, data: { microlink_target: "input", action: "change->microlink#handleChange" } %>
</div>
<%= form.hidden_field :description, data: { microlink_target: "descriptionInput" } %>
<%= form.hidden_field :image, data: { microlink_target: "imageInput" } %>
<%= form.hidden_field :title, data: { microlink_target: "titleInput" } %>
...
<% end %>
- Build
handleChange
method inapp/javascript/controllers/microlink_controller.js
.
import { Controller } from "stimulus"
import mql from "@microlink/mql"
export default class extends Controller {
static targets = [ "input", "descriptionInput", "imageInput", "titleInput" ]
async handleChange() {
const { status, data } = await mql(this.inputTarget.value)
if(status == "success") {
this.setFormData(data);
}
}
setFormData(data) {
this.descriptionInputTarget.value = data?.description ? data?.description : null;
this.imageInputTarget.value = data?.image?.url ? data?.image?.url : null;
this.titleInputTarget.value = data?.title ? data?.title : null;
}
}
Now when a user enters a URL, the hidden fields will be set with the response from the Microlink API.
- Create a
app/views/links/_preview.html.erb
partial.
<div data-microlink-target="output" style="<%= @link.persisted? ? nil : 'display: none;' %>">
<img src="<%= @link.persisted? ? @link.image : nil %>"/>
<div>
<h5><%= @link.persisted? ? @link.title : nil %></h5>
<p><%= @link.persisted? ? @link.description : nil %></p>
</div>
</div>
- Add the partial to
app/views/links/_form.html.erb
.
<%= form_with(model: link, data: { controller: "microlink" }) do |form| %>
...
<%= render "preview" %>
<% end %>
- Add the partial to
app/views/links/show.html.erb
.
<p id="notice"><%= notice %></p>
<p>
<strong>Url:</strong>
<%= @link.url %>
</p>
<%= link_to @link.url, target: "_blank" do %>
<%= render "preview" %>
<% end %>
<%= link_to 'Edit', edit_link_path(@link) %> |
<%= link_to 'Back', links_path %>
- Build the
renderPreview
method inapp/javascript/controllers/microlink_controller.js
.
import { Controller } from "stimulus"
import mql from "@microlink/mql"
export default class extends Controller {
static targets = [ "input", "descriptionInput", "imageInput", "titleInput", "output" ]
connect() {
this.previewDescription = this.outputTarget.querySelector("p");
this.previewImage = this.outputTarget.querySelector("img");
this.previewTitle = this.outputTarget.querySelector("h5");
}
async handleChange() {
const { status, data } = await mql(this.inputTarget.value)
if(status == "success") {
this.setFormData(data);
this.renderPreview(data);
}
}
renderPreview(data) {
this.previewDescription.innerHTML = data?.description ? data.description : null;
data?.image?.url ? this.previewImage.setAttribute("src", data.image.url) : null;
this.previewTitle.innerHTML = data?.title ? data.title : null;
this.outputTarget.style.display = "block";
}
setFormData(data) {
this.descriptionInputTarget.value = data?.description ? data.description : null;
this.imageInputTarget.value = data?.image?.url ? data.image.url : null;
this.titleInputTarget.value = data?.title ? data.title : null;
}
}
At this point you should be able to render a link preview.
Right now we're not actually attaching the image to the Link
but rather we're saving the absolute URL to the image. This means that over time those images could break, since we have no control over them. One solution is to download the image and attach it to the Link
using Active Storage.
- Run
rails active_storage:install
andrails db:migrate
to install Active Storage. - Add
has_one_attached :thumbnail
to Link Model.
class Link < ApplicationRecord
store :meta_data, accessors: [ :description, :image, :title ], coder: JSON
validates :url, presence: true
has_one_attached :thumbnail
end
- Run
bundle add down
to install the down gem. This will make downloading the remote image returned from the Microlink API easier than by doing it with native Ruby. - Run
rails g job microlink_image_attacher
to generate an Active Job. We'll use this Job to download and attach the image returned from the Microlink API.
require "down"
class MicrolinkImageAttacherJob < ApplicationJob
queue_as :default
discard_on Down::InvalidUrl
def perform(link)
if link.image.present?
tempfile = Down.download(link.image)
link.thumbnail.attach(io: tempfile, filename: tempfile.original_filename)
end
end
end
We add
discard_on Down::InvalidUrl
to discard any job that returns aDown::InvalidUrl
exception. This can happen is the Microlink API returns a base64 image.
- Perform MicrolinkImageAttacherJob when a Link is saved.
class LinksController < ApplicationController
...
def create
@link = Link.new(link_params)
respond_to do |format|
if @link.save
MicrolinkImageAttacherJob.perform_now(@link)
...
else
...
end
end
end
end
You could call
perform_later
instead ofperform_now
.
- Render attached thumbnail in
app/views/links/_preview.html.erb
.
<div data-microlink-target="output" style="<%= @link.persisted? ? nil : 'display: none;' %>">
<img src="<%= @link.thumbnail.attached? ? url_for(@link.thumbnail) : nil %>"/>
<div>
<h5><%= @link.persisted? ? @link.title : nil %></h5>
<p><%= @link.persisted? ? @link.description : nil %></p>
</div>
</div>
Now when you save a link that returns an image, it will be saved in Active Storage.
Now that we have our happy path complete, we should improve the UX to account for any errors. Most notably, when someone enters an invalid URL or if the Microlink API returns an error.
- Add markup for rendering a message to
app/views/links/_form.html.erb
.
<%= form_with(model: link, data: { controller: "microlink" }) do |form| %>
...
<div class="field">
<%= form.label :url %>
<%= form.url_field :url, data: { microlink_target: "input", action: "change->microlink#handleChange" } %>
<span data-microlink-target="message"></span>
</div>
...
<%= render "preview" %>
<% end %>
- Update
app/javascript/controllers/microlink_controller.js
to handle errors and render messages.
import { Controller } from "stimulus"
import mql from "@microlink/mql"
export default class extends Controller {
static targets = [ "input", "descriptionInput", "imageInput", "titleInput", "output", "message" ]
connect() {
this.previewDescription = this.outputTarget.querySelector("p");
this.previewImage = this.outputTarget.querySelector("img");
this.previewTitle = this.outputTarget.querySelector("h5");
}
async handleChange() {
this.messageTarget.innerText = null;
this.clearFormData();
this.clearPreview();
if (this.inputTarget.value != "") {
try {
const { status, data } = await mql(this.inputTarget.value)
this.messageTarget.innerText = "Fetching link preview...";
if(status == "success") {
this.setFormData(data);
this.renderPreview(data);
this.messageTarget.innerText = null;
} else {
this.messageTarget.innerText = "There was an error fetching the link preview.";
}
} catch(e) {
this.messageTarget.innerText = e;
}
}
}
clearFormData() {
this.descriptionInputTarget.value = null;
this.imageInputTarget.value = null;
this.titleInputTarget.value = null;
}
clearPreview() {
this.previewDescription.innerHTML = null;
this.previewImage.setAttribute("src", "");
this.previewTitle.innerHTML = null;
this.outputTarget.style.display = "none";
}
renderPreview(data) {
this.previewDescription.innerHTML = data?.description ? data.description : null;
data?.image?.url ? this.previewImage.setAttribute("src", data.image.url) : null;
this.previewTitle.innerHTML = data?.title ? data.title : null;
this.outputTarget.style.display = "block";
}
setFormData(data) {
this.descriptionInputTarget.value = data?.description ? data.description : null;
this.imageInputTarget.value = data?.image?.url ? data.image.url : null;
this.titleInputTarget.value = data?.title ? data.title : null;
}
}