Blog – add keywords

Refs:

Step 1 – add the models

Add a topic model:

bin/rails generate model Keyword word:string

Now create the association with Articles:

rails generate migration CreateJoinTableArticleKeyWord article keyword

Step 2 – Create a CRUD for keywords.

Now we need to create a complete CRUD for the keyword table. It is a lot like the articles model, but simpler.

We need a controller keywords_controller.rb

class KeywordsController < ApplicationController
  def index
    @keywords = Keyword.all
  end

  def show
    @keyword = Keyword.find(params[:id])
  end

  def new
    @keyword = Keyword.new
  end

  def create
    @keyword = Keyword.new(keyword_params)

    if @keyword.save
      redirect_to @keyword
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    @keyword = Keyword.find(params[:id])
  end

  def update
    @keyword = Keyword.find(params[:id])

    if @keyword.update(keyword_params)
      redirect_to @keyword
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @keyword = Keyword.find(params[:id])
    @keyword.destroy

    redirect_to "/keywords", status: :see_other
  end
  
  private
    def keyword_params
      puts "keyword: #{params}"
      params.require(:keyword).permit(:word)
    end

end
Code language: JavaScript (javascript)

We need route resources added to routes.rb

resources :keywordsCode language: CSS (css)

Add new views for keyword. We need to have:

  • index.html.erb
  • new.html.erb
  • show.html.erb
  • edit.html.erb   
  • _form.html.erb 

index.html.erb

<h1>Keywords</h1>

<ul>
  <% @keywords.each do |keyword| %>

    <li>
      <a href="<%= keyword_path(keyword) %>">
        <%= keyword.word %>
      </a>
    </li>

  <% end %>
</ul>

<%= link_to "New Keyword", new_keyword_path %>Code language: HTML, XML (xml)

_form.html.erb

<%= form_with model: keyword do |form| %>
  <div>
    <%= form.label :word %><br>
    <%= form.text_field :word %>
    <% keyword.errors.full_messages_for(:title).each do |message| %>
      <div><%= message %></div>
    <% end %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>Code language: HTML, XML (xml)

show.html.erb

<p>Keyword: <%= @keyword.word %></p>

<ul>
  <li><%= link_to "Edit", edit_keyword_path(@keyword) %></li>

  <li><%= link_to "Destroy", keyword_path(@keyword), data: {
                    turbo_method: :delete,
                    turbo_confirm: "Are you sure?"
                  } %></li>
                  
</ul>
Code language: HTML, XML (xml)

new.html.erb

<h1>New Keyword</h1>

<%= render "form", keyword: @keyword %>Code language: HTML, XML (xml)

edit.html.erb

<h1>Edit Keyword</h1>

<%= render "form", keyword: @keyword %>Code language: HTML, XML (xml)

It should work now.

Now we should add a Keywords option to the menu in the application.html.erb. And also need to make sure the page get reloaded to add the events.

  <head>
    <title>Blog</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <meta name="turbo-visit-control" content="reload">
    <%= javascript_importmap_tags %>
    <script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
  </head>Code language: HTML, XML (xml)
<li class="nav-item">
	<a class="nav-link active" aria-current="page" href="/keywords">Keywords</a>
</li>Code language: HTML, XML (xml)

Step 3 – Add keywords to articles

Set up associations in the models:

article.rb:

class Article < ApplicationRecord
  include Visible

  has_many :comments, dependent: :destroy
  has_and_belongs_to_many :keywords

  
  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }
  
end Code language: CSS (css)

keyword.rb

class Keyword < ApplicationRecord
	has_and_belongs_to_many :articles

endCode language: CSS (css)

This tells the database to set up a special association table articles_keywords.

We then migrate:

rails db:migrateCode language: CSS (css)

You should now be able to do CRUD on keywords.

Step 4 – Add keywords to new articles

We want to add keywords by clicking on buttons:

So we need to add some more code. What we want to do is add the Buttons to the screen when we view the form. If it is a new form, all the buttons start grey. If it is a edit form, we want to highlight the buttons that are in the association table. Lets start withe the add. We need to modify the _form.html.erb, add code to the articles_controller:

_form.html.erb

<%= form_with model: article do |form| %>
  <div>
    <%= form.label :title %><br>
    <%= form.text_field :title %>
    <% article.errors.full_messages_for(:title).each do |message| %>
      <div><%= message %></div>
    <% end %>
  </div>

  <div>
    <%= form.label :body %><br>
    <%= form.text_area :body %><br>
    <% article.errors.full_messages_for(:body).each do |message| %>
      <div><%= message %></div>
    <% end %>
  </div>
  
  	<p>Select Keywords:</p>
  	<div id="keyword_buttons">
	<% Keyword.all.pluck(:word, :id).each do |keyword, keyid| %>
	    <button type="button" class="keyword btn btn-light" value=<%= keyid =%>><%= keyword =%></button>
	<% end %>
  	</div>
  <div>
  <%= form.label :status %><br>
  <%= form.select :status, Visible::VALID_STATUSES, selected: article.status || 'public' %>
  </div>
  
  <input type="hidden" id="article_keywords" name="article[keywords]" value="">
  	

  <div>
    <%= form.submit %>
  </div>
<% end %>Code language: JavaScript (javascript)

This creates a set of buttons for each topix, grey, and adds the keyword ID to the value tag of each button.

The “hidden” input form element is a trick. It allows us to add an input field to the input form that the user doesn’t see. What we will do is pass back a list of the keyword ID’s selected.

Now we need to make a way for the buttons to work, and for a list of the keyword IDs associated with the buttons pushed to be passed back to the controller.

First we need to add javascript to the application at app/javascript/application.js:

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "popper"
import "bootstrap"


function insertKeywordsIntoForm(keywords){
	var para, hiddenInput, br;
	para = document.getElementById('article_keywords');
	//console.log(keywords);
	para.setAttribute('value',keywords);
	return false; //Have this function return true if you want to post right away after adding the hidden value, otherwise leave it to false
}

$(document).ready(function() {
  $('.keyword').click( function() {
    if ($(this).hasClass("btn-light")) {	
    	$(this).addClass('btn-primary').removeClass('btn-light');
    } else if ($(this).hasClass("btn-primary")) {
    	$(this).addClass('btn-light').removeClass('btn-primary');
    }
    const buttons = document.getElementById("keyword_buttons");
	var keywordList = "";
	var first = true;
    for (const child of buttons.children) {
    //console.log(child.value);
  		if (child.classList.contains("btn-primary")) {
  			if (!first) {
  				keywordList+=",";
  			}
  			first = false;
  			keywordList+=child.value;
  		}
	}
    insertKeywordsIntoForm(keywordList);
  });
});Code language: JavaScript (javascript)

This code alternates the buttons color each time they are pressed. It also creates a “value” tag in the parent element <div id=”keyword_buttons”> which will hold the list of keyword IDs for the currently selected, e.g.:

<input type="hidden" id="article_keywords" name="article[keywords]" value="20,21,22">Code language: HTML, XML (xml)

The value ID list is is then passed back from the form when the submit is pushed. These values are collected in the articles controller. Since there are 4 types of values being sent back, these have to be divied into two subsets, the values for the articles, and the values for the keyword association. THis is done in the articles_controller.rb below:

class ArticlesController < ApplicationController

http_basic_authenticate_with name: "jps", password: "kenyon", except: [:index, :show]

  def index
    @articles = Article.all
  end

  def show
    @article = Article.find(params[:id])
  end

  def new
    @article = Article.new
  end

  def create
    # Separate the article from the keyword paramaters
  	@article_args=article_params.slice(:title, :body, :status)
  	@keywords_args=article_params.slice(:keywords)

	# Create a new article
    @article = Article.new(@article_args)
    
    # Get the IDs for the keywords
    keyword_ids = string_to_array(@keywords_args[:keywords])
    # Add an association between the article and each keyword
	keyword_ids.each { |id|
		@keyword = Keyword.find(id)
		@article.keywords << @keyword
	}
	
    if @article.save
      redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
    
    
  end

  def edit
    @article = Article.find(params[:id])
  end

  def update
    @article = Article.find(params[:id])
  	@article_args=article_params.slice(:title, :body, :status)
    @keywords_args=article_params.slice(:keywords)

    # Get the IDs for the keywords
    keyword_ids = string_to_array(@keywords_args[:keywords])
    # Clear the current keyword associations
    @article.keywords.clear
    
    # Add in the new keyword associations
	keyword_ids.each { |id|
		@keyword = Keyword.find(id)
		@article.keywords << @keyword
	}
	
    if @article.update(@article_args)
      redirect_to @article
    else
      render :edit, status: :unprocessable_entity
    end
  end
  
  def destroy
    @article = Article.find(params[:id])
    @article.destroy

    redirect_to root_path, status: :see_other
  end
  
  private
    def article_params
      puts "article: #{params}"
      params.require(:article).permit(:title, :body, :status, :keywords)
    end

    def string_to_array(string) 
  		string.scan(/\d+/).map(&:to_i) 
	end 

end
Note that <span style="background-color: initial; font-family: inherit; font-size: inherit; color: initial;">article_params</span> now includes <span style="background-color: rgba(0, 0, 0, 0.2); font-family: inherit; font-size: inherit; color: initial;">:keywords</span>. This would cause <span style="background-color: initial; font-family: inherit; font-size: inherit; color: initial;"><code>Article.new(@article_args)</code> </span>to fail, since there is an extra keyword. So we fix this by slicing the extra elements with two lines of code:
Code language: HTML, XML (xml)
    @article_args=article_params.slice(:title, :body, :status)
    @keywords_args=article_params.slice(:keywords)

We then use the @article_args to create teh new article, and @keywords_args to add the keywords.

The keyword adding is simple:

    # Add in the new keyword associations
	keyword_ids.each { |id|
		@keyword = Keyword.find(id)
		@article.keywords << @keyword
	}Code language: PHP (php)

This just iterates over the keyword IDs, and then the @keyword = Keyword.find(id) adds the association the the articles_keywords association table, seen below:

Step 5 – Editting keywords for articles

Next we want to edit an article, and change the keywords. What we need to do is when we edit a article, start by highlighting the current keywords, then allow them to be clicked on to change them. When the submission is made, it works like the add, only it deletes the current keyword associations first.

The article form thus needs to be changed so that it works differently if it is an add vs an edit.

Here is the new article form:


<%= form_with model: article do |form| %>
  <div>
    <%= form.label :title %><br>
    <%= form.text_field :title %>
    <% article.errors.full_messages_for(:title).each do |message| %>
      <div><%= message %></div>
    <% end %>
  </div>

  <div>
    <%= form.label :body %><br>
    <%= form.text_area :body %><br>
    <% article.errors.full_messages_for(:body).each do |message| %>
      <div><%= message %></div>
    <% end %>
  </div>
  
  	<p>Select Keywords:</p>
  	<div id="keyword_buttons"">
	<% Keyword.all.pluck(:word, :id).each do |keyword, keyid| %>
	  <% if action=="edit" && Article.find(article.id).keywords.find_by(id: keyid) %>
	    <button type="button" class="keyword btn btn-primary" value=<%= keyid =%>><%= keyword =%></button>
	  <% else %>
	    <button type="button" class="keyword btn btn-light" value=<%= keyid =%>><%= keyword =%></button>
	  <% end %>
	<% end %>
  	</div>
  <div>
  <%= form.label :status %><br>
  <%= form.select :status, Visible::VALID_STATUSES, selected: article.status || 'public' %>
  </div>
  
  <input type="hidden" id="article_keywords" name="article[keywords]" value="">
  	

  <div>
    <%= form.submit %>
  </div>
<% end %>Code language: PHP (php)

Notice how it uses the “action” variable, which will be “edit” or “new”, to decide how to create the keyword buttons. We need to pass this variable from articles views for new and edit:

new.html.erb

<h1>New Article</h1>

<%= render "form", article: @article, :action => "new" %>Code language: PHP (php)

edit.html.erb

<h1>Edit Article</h1>

<%= render "form", article: @article, :action => "edit" %>

Code language: HTML, XML (xml)
Scroll to Top