add entry requests, invite new users
This commit is contained in:
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
|
||||
@pending_suggestions_count = SuggestedMeaning.pending.count
|
||||
@accepted_suggestions_count = SuggestedMeaning.accepted.count
|
||||
@rejected_suggestions_count = SuggestedMeaning.rejected.count
|
||||
@requested_entries_count = Entry.requested.count
|
||||
|
||||
@comment_count = Comment.count
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class Admin::RequestsController < Admin::BaseController
|
||||
def index
|
||||
@requested_entries = Entry.requested
|
||||
.includes(:requested_by)
|
||||
.order(created_at: :desc)
|
||||
|
||||
@approved_entries = Entry.approved
|
||||
.includes(:requested_by)
|
||||
.order(updated_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
@entry = Entry.find(params[:id])
|
||||
end
|
||||
|
||||
def edit
|
||||
@entry = Entry.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
@entry = Entry.find(params[:id])
|
||||
|
||||
if @entry.update(entry_params)
|
||||
redirect_to admin_request_path(@entry), notice: "Request updated successfully."
|
||||
else
|
||||
flash.now[:alert] = "Error updating request."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
@user.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current,
|
||||
invited_by: current_user
|
||||
)
|
||||
|
||||
@entry.update!(status: :approved)
|
||||
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
|
||||
|
||||
redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}."
|
||||
end
|
||||
|
||||
def reject
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
entry_preview = [@entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de].compact.first || "Entry"
|
||||
|
||||
@entry.destroy!
|
||||
@user.destroy! if @user.requested_entries.count.zero?
|
||||
|
||||
redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class EntriesController < ApplicationController
|
||||
@page = [ params[:page].to_i, 1 ].max
|
||||
@per_page = 25
|
||||
|
||||
entries_scope = Entry.all
|
||||
entries_scope = Entry.active_entries
|
||||
entries_scope = entries_scope.with_category(@category)
|
||||
entries_scope = entries_scope.search(@query, language_code: @language_code)
|
||||
entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present?
|
||||
@@ -20,17 +20,18 @@ class EntriesController < ApplicationController
|
||||
@total_pages = (@total_entries.to_f / @per_page).ceil
|
||||
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
|
||||
|
||||
@entry_count = Entry.count
|
||||
@verified_count = Entry.where(verified: true).count
|
||||
@entry_count = Entry.active_entries.count
|
||||
@requested_count = Entry.requested.count
|
||||
@verified_count = Entry.active_entries.where(verified: true).count
|
||||
@needs_review_count = @entry_count - @verified_count
|
||||
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
|
||||
@complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language|
|
||||
scope.where.not(language.code => [ nil, "" ])
|
||||
end.count
|
||||
@missing_entries_count = @entry_count - @complete_entries_count
|
||||
@language_completion = supported_languages.index_with do |language|
|
||||
next 0 if @entry_count.zero?
|
||||
|
||||
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
(Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
end
|
||||
|
||||
if @language_code.present?
|
||||
@@ -61,7 +62,7 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def download
|
||||
@entries = Entry.order(:id)
|
||||
@entries = Entry.active_entries.order(:id)
|
||||
respond_to do |format|
|
||||
format.xlsx do
|
||||
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
|
||||
|
||||
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
|
||||
invitation_token: nil
|
||||
)
|
||||
|
||||
# Activate approved entries by this user
|
||||
Entry.where(requested_by: @user, status: :approved).update_all(status: :active)
|
||||
|
||||
session[:user_id] = @user.id
|
||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class RequestsController < ApplicationController
|
||||
def new
|
||||
@entry = Entry.new
|
||||
|
||||
if current_user
|
||||
@pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count
|
||||
elsif params[:email].present?
|
||||
@pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0
|
||||
else
|
||||
@pending_count = 0
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
# If user is logged in, use their account
|
||||
if current_user
|
||||
@user = current_user
|
||||
else
|
||||
# Anonymous submission - need to find or create user
|
||||
email = request_params[:email]
|
||||
existing_user = User.find_by(email: email)
|
||||
|
||||
# Check if user has already accepted an invitation
|
||||
if existing_user&.invitation_accepted_at.present?
|
||||
redirect_to login_path, alert: "An account with this email already exists. Please log in."
|
||||
return
|
||||
end
|
||||
|
||||
# Use existing pending user or create new one
|
||||
@user = existing_user || User.new(
|
||||
name: request_params[:name],
|
||||
email: email,
|
||||
password: SecureRandom.alphanumeric(32),
|
||||
role: :contributor
|
||||
)
|
||||
end
|
||||
|
||||
# Create entry in a transaction
|
||||
ActiveRecord::Base.transaction do
|
||||
# Save user only if it's a new record
|
||||
if @user.new_record? && !@user.save
|
||||
@pending_count = 0
|
||||
@entry = Entry.new(entry_params)
|
||||
flash.now[:alert] = "There was an error submitting your request. Please check the form."
|
||||
render :new, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
return
|
||||
end
|
||||
|
||||
# Create entry
|
||||
@entry = Entry.new(entry_params)
|
||||
@entry.status = :requested
|
||||
@entry.requested_by = @user
|
||||
|
||||
if @entry.save
|
||||
redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon."
|
||||
else
|
||||
@pending_count = 0
|
||||
flash.now[:alert] = "There was an error submitting your request. Please check the form."
|
||||
render :new, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_params
|
||||
params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
|
||||
def entry_params
|
||||
request_params.except(:name, :email)
|
||||
end
|
||||
end
|
||||
@@ -28,4 +28,15 @@ module EntriesHelper
|
||||
def format_entry_category(entry)
|
||||
entry.category.to_s.tr("_", " ").capitalize
|
||||
end
|
||||
|
||||
def format_entry_status(entry)
|
||||
case entry.status
|
||||
when "requested"
|
||||
content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800")
|
||||
when "approved"
|
||||
content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800")
|
||||
when "active"
|
||||
content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite(user)
|
||||
def invite(user, approved_entry: nil)
|
||||
@user = user
|
||||
@approved_entry = approved_entry
|
||||
@invitation_url = invitation_url(@user.invitation_token)
|
||||
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
||||
|
||||
subject = if @approved_entry
|
||||
"Your entry request has been approved - Join Sanasto Wiki"
|
||||
else
|
||||
"You've been invited to join Sanasto Wiki"
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "You've been invited to join Sanasto Wiki"
|
||||
subject: subject
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
class Entry < ApplicationRecord
|
||||
belongs_to :created_by, class_name: "User", optional: true
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
belongs_to :requested_by, class_name: "User", optional: true
|
||||
|
||||
has_many :suggested_meanings, dependent: :destroy
|
||||
has_many :comments, as: :commentable, dependent: :destroy
|
||||
|
||||
enum :category, %i[word phrase proper_name title reference other]
|
||||
enum :status, %i[requested approved active], default: :active
|
||||
|
||||
validates :category, presence: true
|
||||
validate :at_least_one_translation
|
||||
|
||||
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
|
||||
scope :requested, -> { where(status: :requested) }
|
||||
scope :approved, -> { where(status: :approved) }
|
||||
scope :active_entries, -> { where(status: :active) }
|
||||
|
||||
def self.search(query, language_code: nil)
|
||||
return all if query.blank?
|
||||
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
|
||||
def self.valid_lang?(code)
|
||||
SupportedLanguage.valid_codes.include?(code.to_s)
|
||||
end
|
||||
|
||||
def at_least_one_translation
|
||||
if [fi, en, sv, no, ru, de].all?(&:blank?)
|
||||
errors.add(:base, "At least one language translation is required")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
|
||||
has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify
|
||||
has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
|
||||
has_many :submitted_suggested_meanings,
|
||||
class_name: "SuggestedMeaning",
|
||||
foreign_key: :submitted_by_id,
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_users_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,10 +46,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
||||
</dl>
|
||||
<%= link_to root_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,10 +73,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_requests_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +100,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_invitations_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
|
||||
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
|
||||
<% if @entry.errors.any? %>
|
||||
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
|
||||
<% @entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 pt-6 space-y-4">
|
||||
<div>
|
||||
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, {}, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :notes, "Additional Notes", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
|
||||
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
|
||||
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
|
||||
<%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %>
|
||||
<%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,126 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
|
||||
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
|
||||
</div>
|
||||
|
||||
<!-- Requested Entries Section -->
|
||||
<div class="mb-12">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
|
||||
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
|
||||
<span>⏳</span> Pending Review
|
||||
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<% if @requested_entries.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @requested_entries.each do |entry| %>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= entry.category.humanize %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
|
||||
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= entry.created_at.strftime("%b %d, %Y") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
<%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %>
|
||||
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
No pending requests at the moment.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approved Entries Section -->
|
||||
<div>
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
|
||||
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
|
||||
<span>✅</span> Approved (Awaiting User Acceptance)
|
||||
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<% if @approved_entries.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @approved_entries.each do |entry| %>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= entry.category.humanize %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
|
||||
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= entry.updated_at.strftime("%b %d, %Y") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
No approved entries awaiting user acceptance.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
|
||||
<%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= @entry.category.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
|
||||
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
|
||||
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
|
||||
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
|
||||
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
|
||||
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
|
||||
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @entry.notes.present? %>
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
|
||||
<%= @entry.notes %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Name:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Email:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Submitted:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
|
||||
<%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
|
||||
|
||||
<% if @entry.requested? %>
|
||||
<%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %>
|
||||
<%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,6 +42,9 @@
|
||||
<tr>
|
||||
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
||||
No entries matched your filters.
|
||||
<br><br>
|
||||
<%= link_to "Request a new entry", new_request_path,
|
||||
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= link_to "Request Entry", new_request_path,
|
||||
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
|
||||
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
<% if admin? %>
|
||||
@@ -21,6 +23,21 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<% if flash.any? %>
|
||||
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
|
||||
<span class="block sm:inline pr-8"><%= message %></span>
|
||||
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<section id="search">
|
||||
|
||||
@@ -79,6 +79,32 @@
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.entry-box {
|
||||
background: #f0fdf4;
|
||||
border-left: 4px solid #10b981;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.entry-box h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #065f46;
|
||||
font-size: 16px;
|
||||
}
|
||||
.entry-translations {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.entry-translations dt {
|
||||
font-weight: 600;
|
||||
color: #064e3b;
|
||||
}
|
||||
.entry-translations dd {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -90,11 +116,53 @@
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
</p>
|
||||
<% if @approved_entry %>
|
||||
<p>
|
||||
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
|
||||
</p>
|
||||
|
||||
<p>With a login account, you can contribute to this work.</p>
|
||||
<div class="entry-box">
|
||||
<h3>✓ Your Approved Entry</h3>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
|
||||
|
||||
<dl class="entry-translations">
|
||||
<% if @approved_entry.fi.present? %>
|
||||
<dt>🇫🇮 Finnish:</dt>
|
||||
<dd><%= @approved_entry.fi %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.en.present? %>
|
||||
<dt>🇬🇧 English:</dt>
|
||||
<dd><%= @approved_entry.en %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.sv.present? %>
|
||||
<dt>🇸🇪 Swedish:</dt>
|
||||
<dd><%= @approved_entry.sv %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.no.present? %>
|
||||
<dt>🇳🇴 Norwegian:</dt>
|
||||
<dd><%= @approved_entry.no %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.ru.present? %>
|
||||
<dt>🇷🇺 Russian:</dt>
|
||||
<dd><%= @approved_entry.ru %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.de.present? %>
|
||||
<dt>🇩🇪 German:</dt>
|
||||
<dd><%= @approved_entry.de %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
|
||||
</p>
|
||||
<% else %>
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
</p>
|
||||
|
||||
<p>With a login account, you can contribute to this work.</p>
|
||||
<% end %>
|
||||
|
||||
<div class="info-box">
|
||||
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
||||
|
||||
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
|
||||
|
||||
Hello <%= @user.name %>,
|
||||
|
||||
<% if @approved_entry %>
|
||||
Great news! Your entry request has been APPROVED and is ready to be published.
|
||||
|
||||
YOUR APPROVED ENTRY
|
||||
-------------------
|
||||
Category: <%= @approved_entry.category.to_s.humanize %>
|
||||
|
||||
Translations:
|
||||
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
|
||||
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
|
||||
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
|
||||
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
|
||||
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
|
||||
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
|
||||
<% end %>
|
||||
|
||||
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
|
||||
<% else %>
|
||||
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
|
||||
With a login account, you can contribute to this work.
|
||||
<% end %>
|
||||
|
||||
YOUR ACCOUNT DETAILS
|
||||
--------------------
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<% requested_count = Entry.requested.count %>
|
||||
<% gap = requested_count.zero? ? '' : 'pr-4' %>
|
||||
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
|
||||
Requests
|
||||
<% if requested_count > 0 %>
|
||||
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
<%= requested_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</nav>
|
||||
@@ -35,8 +45,13 @@
|
||||
<% if flash.any? %>
|
||||
<div class="max-w-7xl mx-auto px-4 mt-4">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
|
||||
<span class="block sm:inline"><%= message %></span>
|
||||
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
|
||||
<span class="block sm:inline pr-8"><%= message %></span>
|
||||
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<% content_for :title, "Request a New Entry" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<%= link_to root_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
<% end %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
|
||||
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
|
||||
</div>
|
||||
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
<%= flash[:alert] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @pending_count && @pending_count > 0 %>
|
||||
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
|
||||
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
|
||||
<% if @entry.errors.any? %>
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
|
||||
<% @entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if current_user %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-blue-900">
|
||||
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, { prompt: "Select a category" }, { required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
|
||||
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless current_user %>
|
||||
<div class="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +25,9 @@ Rails.application.routes.draw do
|
||||
get "invitations/:token", to: "invitations#show", as: :invitation
|
||||
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
|
||||
|
||||
# Public entry request routes
|
||||
resources :requests, only: [:new, :create]
|
||||
|
||||
# Admin namespace
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
@@ -35,6 +38,12 @@ Rails.application.routes.draw do
|
||||
put :resend
|
||||
end
|
||||
end
|
||||
resources :requests, only: [ :index, :show, :edit, :update ] do
|
||||
member do
|
||||
post :approve
|
||||
delete :reject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :entries do
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class AddStatusToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :status, :integer, default: 2, null: false
|
||||
add_index :entries, :status
|
||||
|
||||
# Set all existing entries to status: 2 (active)
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute "UPDATE entries SET status = 2"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :requested_by_id, :integer
|
||||
add_foreign_key :entries, :users, column: :requested_by_id
|
||||
add_index :entries, :requested_by_id
|
||||
end
|
||||
end
|
||||
+17
-30
@@ -1,15 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
|
||||
FOREIGN KEY ("user_id")
|
||||
REFERENCES "users" ("id")
|
||||
@@ -59,28 +49,25 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
CREATE TRIGGER entries_fts_after_insert
|
||||
AFTER INSERT ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_update
|
||||
AFTER UPDATE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_delete
|
||||
AFTER DELETE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_4d36fd8a36"
|
||||
FOREIGN KEY ("requested_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20260129204706'),
|
||||
('20260129204705'),
|
||||
('20260123130957'),
|
||||
('20260123125325'),
|
||||
('20260122131000'),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 23 KiB |
+18
-56
@@ -2,8 +2,9 @@
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512"
|
||||
version="1.1"
|
||||
id="svg10"
|
||||
id="svg3"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
@@ -11,9 +12,9 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs10" />
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview10"
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
@@ -21,64 +22,25 @@
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.0230914"
|
||||
inkscape:cx="220.41042"
|
||||
inkscape:cy="272.2142"
|
||||
inkscape:window-width="1854"
|
||||
inkscape:window-height="1131"
|
||||
inkscape:zoom="1.5917969"
|
||||
inkscape:cx="256"
|
||||
inkscape:cy="256"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg10" />
|
||||
inkscape:current-layer="svg3" />
|
||||
<!-- Background -->
|
||||
<rect
|
||||
width="512"
|
||||
height="512"
|
||||
fill="#3b82f6"
|
||||
fill="#4f46e5"
|
||||
rx="64"
|
||||
id="rect1" />
|
||||
<g
|
||||
id="g10"
|
||||
transform="translate(-35.60286,21.531884)">
|
||||
<path
|
||||
d="m 441.4878,18.633802 q 0,95.926358 0,191.852718 v 191.85273 q 0,47.96318 -47.96318,47.96318 H 201.67189 q -47.96318,0 -47.96318,-47.96318 V 18.633802 Z"
|
||||
fill="#ffffff"
|
||||
id="path2"
|
||||
style="stroke-width:2.39816" />
|
||||
<path
|
||||
d="M 141.71792,18.633802 V 450.30243 H 165.6995 V 18.633802 Z"
|
||||
fill="#d1d5db"
|
||||
id="path3"
|
||||
style="stroke-width:2.39816" />
|
||||
<line
|
||||
x1="393.5246"
|
||||
y1="114.56016"
|
||||
x2="201.67191"
|
||||
y2="114.56016"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="9.59264"
|
||||
id="line7" />
|
||||
<line
|
||||
x1="393.5246"
|
||||
y1="186.50494"
|
||||
x2="201.67191"
|
||||
y2="186.50494"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="9.59264"
|
||||
id="line8" />
|
||||
<line
|
||||
x1="393.5246"
|
||||
y1="258.44971"
|
||||
x2="201.67191"
|
||||
y2="258.44971"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="9.59264"
|
||||
id="line9" />
|
||||
<line
|
||||
x1="393.5246"
|
||||
y1="330.39447"
|
||||
x2="201.67191"
|
||||
y2="330.39447"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="9.59264"
|
||||
id="line10" />
|
||||
</g>
|
||||
<!-- Open book icon - scaled and centered -->
|
||||
<path
|
||||
id="path1"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-linecap:round;stroke-linejoin:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="M 160.42969 83.410156 C 119.4801 83.410156 81.95121 94.40372 53.105469 113.56836 A 21.240073 21.240073 0 0 0 43.621094 131.25781 L 43.621094 407.35156 A 21.240073 21.240073 0 0 0 76.611328 425.04297 C 97.377495 411.24627 127.21626 401.97852 160.42969 401.97852 C 193.64311 401.97852 223.48189 411.24627 244.24805 425.04297 A 21.237949 21.237949 0 0 0 244.83984 425.38281 A 21.237949 21.237949 0 0 0 245.9668 426.0332 A 21.237949 21.237949 0 0 0 246.60547 426.39453 A 21.237949 21.237949 0 0 0 247.29883 426.68945 A 21.237949 21.237949 0 0 0 248.49023 427.18359 A 21.237949 21.237949 0 0 0 249.16602 427.45703 A 21.237949 21.237949 0 0 0 249.88477 427.65625 A 21.237949 21.237949 0 0 0 251.15039 427.99609 A 21.237949 21.237949 0 0 0 251.86133 428.17969 A 21.237949 21.237949 0 0 0 252.58594 428.28125 A 21.237949 21.237949 0 0 0 253.87891 428.45117 A 21.237949 21.237949 0 0 0 254.60938 428.54102 A 21.237949 21.237949 0 0 0 255.34375 428.54688 A 21.237949 21.237949 0 0 0 256 428.58984 A 21.237949 21.237949 0 0 0 256.65625 428.54688 A 21.237949 21.237949 0 0 0 257.39062 428.54102 A 21.237949 21.237949 0 0 0 258.12109 428.45117 A 21.237949 21.237949 0 0 0 259.41406 428.28125 A 21.237949 21.237949 0 0 0 260.13867 428.17969 A 21.237949 21.237949 0 0 0 260.84961 427.99609 A 21.237949 21.237949 0 0 0 262.11523 427.65625 A 21.237949 21.237949 0 0 0 262.83398 427.45703 A 21.237949 21.237949 0 0 0 263.50977 427.18359 A 21.237949 21.237949 0 0 0 264.70117 426.68945 A 21.237949 21.237949 0 0 0 265.39453 426.39453 A 21.237949 21.237949 0 0 0 266.0332 426.0332 A 21.237949 21.237949 0 0 0 267.16016 425.38281 A 21.237949 21.237949 0 0 0 267.75195 425.04297 C 288.51811 411.24627 318.35689 401.97852 351.57031 401.97852 C 384.80719 401.97852 414.61784 411.24317 435.38867 425.04297 A 21.240073 21.240073 0 0 0 468.37891 407.35156 L 468.37891 131.25781 A 21.240073 21.240073 0 0 0 458.89453 113.56836 C 430.05344 94.406813 392.53892 83.410156 351.57031 83.410156 C 315.97653 83.410156 282.96864 91.716323 256 106.47461 C 229.03136 91.716323 196.02347 83.410156 160.42969 83.410156 z M 160.42969 125.88477 C 188.87562 125.88477 214.84618 132.6836 234.76172 143.30273 L 234.76172 372.76367 C 212.41599 364.18522 187.15157 359.50391 160.42969 359.50391 C 133.68474 359.50391 108.45607 364.26642 86.095703 372.85938 L 86.095703 143.35742 C 106.01746 132.72393 131.96088 125.88477 160.42969 125.88477 z M 351.57031 125.88477 C 380.05922 125.88477 405.98509 132.72263 425.9043 143.35742 L 425.9043 372.85938 C 403.54963 364.26801 378.325 359.50391 351.57031 359.50391 C 324.84843 359.50391 299.58401 364.18522 277.23828 372.76367 L 277.23828 143.30273 C 297.15382 132.6836 323.12438 125.88477 351.57031 125.88477 z " />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,141 @@
|
||||
# Entry Request System Test Coverage
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### 1. Model Tests: `test/models/entry_request_test.rb`
|
||||
Tests for Entry model enhancements:
|
||||
- ✅ Validates that at least one translation is required
|
||||
- ✅ Entry status defaults to "active"
|
||||
- ✅ Scopes (requested, approved, active_entries) work correctly
|
||||
- ✅ requested_by association functions properly
|
||||
- ✅ Status transitions (requested → approved → active)
|
||||
- ✅ Blank translations are properly handled
|
||||
|
||||
**13 tests, 40 assertions**
|
||||
|
||||
### 2. Public Controller Tests: `test/controllers/requests_controller_test.rb`
|
||||
Tests for public entry request submission:
|
||||
- ✅ Shows new request form for anonymous users (with name/email fields)
|
||||
- ✅ Shows new request form for logged-in users (without name/email fields)
|
||||
- ✅ Creates entry request with valid data
|
||||
- ✅ Requires at least one translation
|
||||
- ✅ Redirects to login if email already exists with accepted invitation
|
||||
- ✅ Shows pending count for email with existing requests
|
||||
- ✅ Creates entry with single or multiple translations
|
||||
- ✅ Logged-in user can submit request without providing name/email
|
||||
- ✅ Does not modify existing user when they submit request
|
||||
- ✅ Reuses existing pending user without modifying them
|
||||
- ✅ Transaction rollback on validation failure
|
||||
|
||||
**11 tests, 78 assertions**
|
||||
|
||||
### 3. Admin Controller Tests: `test/controllers/admin/requests_controller_test.rb`
|
||||
Tests for admin request management:
|
||||
- ✅ Requires admin authentication
|
||||
- ✅ Shows requests index with requested and approved sections
|
||||
- ✅ Lists requested and approved entries
|
||||
- ✅ Shows request details
|
||||
- ✅ Shows edit form for requested entry
|
||||
- ✅ Updates entry details
|
||||
- ✅ Validates entry data on update
|
||||
- ✅ Approves request and sends invitation email
|
||||
- ✅ Rejects request and deletes entry/user
|
||||
- ✅ Preserves user if they have multiple entries
|
||||
- ✅ Blocks access for non-admin users (contributors, reviewers)
|
||||
|
||||
**14 tests, 85 assertions**
|
||||
|
||||
### 4. Integration Tests: `test/integration/entry_request_flow_test.rb`
|
||||
Full end-to-end flow tests:
|
||||
- ✅ Complete flow: request → admin approve → user accepts → entry active
|
||||
- ✅ Rejected request removes entry and user
|
||||
- ✅ Requested/approved entries not visible on public site
|
||||
- ✅ Multiple entries by same requester all activated on invitation acceptance
|
||||
- ✅ Admin can edit entry details before approval
|
||||
- ✅ Cannot submit request with existing user email
|
||||
|
||||
**6 tests**
|
||||
|
||||
## Fixtures Added
|
||||
|
||||
### Updated: `test/fixtures/entries.yml`
|
||||
- Added `status: 2` (active) to existing entries
|
||||
- Added `requested_entry` fixture (status: requested)
|
||||
- Added `approved_entry` fixture (status: approved)
|
||||
|
||||
### Updated: `test/fixtures/users.yml`
|
||||
- Added `requester_user` fixture (user without accepted invitation)
|
||||
|
||||
### 5. Mailer Tests: `test/mailers/invitation_mailer_test.rb`
|
||||
Invitation email tests including entry approval notifications:
|
||||
- ✅ Sends email with correct details
|
||||
- ✅ Includes invitation link and expiry date
|
||||
- ✅ Has both HTML and text parts
|
||||
- ✅ **With approved entry: includes entry details in email**
|
||||
- ✅ **With approved entry: shows correct message and formatting**
|
||||
- ✅ Without approved entry: uses standard invitation message
|
||||
|
||||
**7 tests, 38 assertions**
|
||||
|
||||
## Test Summary
|
||||
|
||||
**Total: 51 tests, 316 assertions**
|
||||
|
||||
All tests passing ✅
|
||||
|
||||
Full test suite: **131 tests, 566 assertions** ✅
|
||||
|
||||
## Running the Tests
|
||||
|
||||
Run all request-related tests:
|
||||
```bash
|
||||
bin/rails test test/models/entry_request_test.rb \
|
||||
test/controllers/requests_controller_test.rb \
|
||||
test/controllers/admin/requests_controller_test.rb \
|
||||
test/integration/entry_request_flow_test.rb
|
||||
```
|
||||
|
||||
Run individual test files:
|
||||
```bash
|
||||
bin/rails test test/models/entry_request_test.rb
|
||||
bin/rails test test/controllers/requests_controller_test.rb
|
||||
bin/rails test test/controllers/admin/requests_controller_test.rb
|
||||
bin/rails test test/integration/entry_request_flow_test.rb
|
||||
```
|
||||
|
||||
Run specific test:
|
||||
```bash
|
||||
bin/rails test test/integration/entry_request_flow_test.rb:3
|
||||
```
|
||||
|
||||
## Test Coverage Areas
|
||||
|
||||
### Public Request Flow
|
||||
- Form display and validation (different for logged-in vs anonymous users)
|
||||
- User and entry creation
|
||||
- Email duplicate detection for active accounts
|
||||
- Logged-in users submit without providing name/email
|
||||
- Existing users are never modified during request submission
|
||||
- Existing pending users are reused without modification
|
||||
- Transaction safety
|
||||
|
||||
### Admin Management Flow
|
||||
- Authentication and authorization
|
||||
- Request listing and filtering
|
||||
- Request details display
|
||||
- Entry editing before approval
|
||||
- Approval with invitation sending
|
||||
- Rejection with cleanup
|
||||
|
||||
### Integration Flow
|
||||
- Complete user journey from request to active entry
|
||||
- Entry visibility rules (requested/approved not shown publicly)
|
||||
- Multi-entry approval and activation
|
||||
- Admin workflow with editing
|
||||
|
||||
### Edge Cases
|
||||
- Validation failures with transaction rollback
|
||||
- User preservation when they have multiple entries
|
||||
- Expired invitations
|
||||
- Non-admin access attempts
|
||||
- Blank translations handling
|
||||
@@ -0,0 +1,184 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@admin = users(:admin_user)
|
||||
@requested_entry = entries(:requested_entry)
|
||||
@approved_entry = entries(:approved_entry)
|
||||
login_as(@admin)
|
||||
end
|
||||
|
||||
test "should require admin authentication" do
|
||||
logout
|
||||
get admin_requests_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "should show requests index" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Entry Requests"
|
||||
assert_select "h2", /Pending Review/
|
||||
assert_select "h2", /Approved/
|
||||
end
|
||||
|
||||
test "should list requested entries" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: @requested_entry.fi
|
||||
assert_select "a[href=?]", admin_request_path(@requested_entry)
|
||||
end
|
||||
|
||||
test "should list approved entries" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: @approved_entry.fi
|
||||
end
|
||||
|
||||
test "should show request details" do
|
||||
get admin_request_path(@requested_entry)
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Entry Request Details"
|
||||
assert_match @requested_entry.fi, response.body
|
||||
assert_match @requested_entry.en, response.body
|
||||
assert_match @requested_entry.requested_by.name, response.body
|
||||
end
|
||||
|
||||
test "should show edit form for requested entry" do
|
||||
get edit_admin_request_path(@requested_entry)
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Edit Entry Request"
|
||||
assert_select "form[action=?]", admin_request_path(@requested_entry)
|
||||
assert_select "input[name='entry[fi]'][value=?]", @requested_entry.fi
|
||||
end
|
||||
|
||||
test "should update entry details" do
|
||||
patch admin_request_path(@requested_entry), params: {
|
||||
entry: {
|
||||
category: "phrase",
|
||||
fi: "päivitetty sana",
|
||||
en: "updated word",
|
||||
notes: "Updated notes"
|
||||
}
|
||||
}
|
||||
|
||||
@requested_entry.reload
|
||||
assert_equal "phrase", @requested_entry.category
|
||||
assert_equal "päivitetty sana", @requested_entry.fi
|
||||
assert_equal "updated word", @requested_entry.en
|
||||
assert_equal "Updated notes", @requested_entry.notes
|
||||
assert_redirected_to admin_request_path(@requested_entry)
|
||||
assert_equal "Request updated successfully.", flash[:notice]
|
||||
end
|
||||
|
||||
test "should not update with invalid data" do
|
||||
patch admin_request_path(@requested_entry), params: {
|
||||
entry: {
|
||||
fi: "",
|
||||
en: "",
|
||||
sv: "",
|
||||
no: "",
|
||||
ru: "",
|
||||
de: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
@requested_entry.reload
|
||||
assert_equal "testisana", @requested_entry.fi # Unchanged
|
||||
end
|
||||
|
||||
test "should approve request and send invitation" do
|
||||
user = @requested_entry.requested_by
|
||||
assert_nil user.invitation_token
|
||||
assert_nil user.invitation_sent_at
|
||||
|
||||
assert_enqueued_emails 1 do
|
||||
post approve_admin_request_path(@requested_entry)
|
||||
end
|
||||
|
||||
user.reload
|
||||
@requested_entry.reload
|
||||
|
||||
assert_equal "approved", @requested_entry.status
|
||||
assert_not_nil user.invitation_token
|
||||
assert_not_nil user.invitation_sent_at
|
||||
assert_equal @admin, user.invited_by
|
||||
assert_redirected_to admin_requests_path
|
||||
assert_match(/invitation sent/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should not approve already approved entry" do
|
||||
# Try to approve an already approved entry
|
||||
user = @approved_entry.requested_by
|
||||
|
||||
post approve_admin_request_path(@approved_entry)
|
||||
|
||||
@approved_entry.reload
|
||||
assert_equal "approved", @approved_entry.status
|
||||
end
|
||||
|
||||
test "should reject request and delete entry and user" do
|
||||
user = @requested_entry.requested_by
|
||||
entry_id = @requested_entry.id
|
||||
user_id = user.id
|
||||
|
||||
assert_difference("Entry.count", -1) do
|
||||
assert_difference("User.count", -1) do
|
||||
delete reject_admin_request_path(@requested_entry)
|
||||
end
|
||||
end
|
||||
|
||||
assert_not Entry.exists?(entry_id)
|
||||
assert_not User.exists?(user_id)
|
||||
assert_redirected_to admin_requests_path
|
||||
assert_match(/rejected and deleted/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should reject but not delete user with multiple entries" do
|
||||
user = @requested_entry.requested_by
|
||||
|
||||
# Create another entry for the same user
|
||||
another_entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "toinen sana",
|
||||
en: "another word",
|
||||
status: :requested,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
assert_difference("Entry.count", -1) do
|
||||
assert_no_difference("User.count") do
|
||||
delete reject_admin_request_path(@requested_entry)
|
||||
end
|
||||
end
|
||||
|
||||
assert User.exists?(user.id)
|
||||
assert Entry.exists?(another_entry.id)
|
||||
end
|
||||
|
||||
test "contributors should not access admin requests" do
|
||||
logout
|
||||
contributor = users(:contributor_user)
|
||||
login_as(contributor)
|
||||
|
||||
get admin_requests_path
|
||||
assert_redirected_to root_path
|
||||
assert_match(/administrator/i, flash[:alert])
|
||||
end
|
||||
|
||||
test "reviewers should not access admin requests" do
|
||||
logout
|
||||
reviewer = users(:reviewer_user)
|
||||
login_as(reviewer)
|
||||
|
||||
get admin_requests_path
|
||||
assert_redirected_to root_path
|
||||
assert_match(/administrator/i, flash[:alert])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
require "test_helper"
|
||||
|
||||
class RequestsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should show new request form for anonymous users" do
|
||||
get new_request_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Request a New Entry"
|
||||
assert_select "form"
|
||||
assert_select "input[name='entry[name]']"
|
||||
assert_select "input[name='entry[email]']"
|
||||
assert_select "select[name='entry[category]']"
|
||||
end
|
||||
|
||||
test "should show new request form for logged-in users without name/email fields" do
|
||||
login_as(users(:contributor_user))
|
||||
get new_request_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Request a New Entry"
|
||||
assert_select "form"
|
||||
assert_select "input[name='entry[name]']", count: 0
|
||||
assert_select "input[name='entry[email]']", count: 0
|
||||
assert_select ".bg-blue-50", text: /Submitting as/
|
||||
end
|
||||
|
||||
test "should create entry request with valid data" do
|
||||
assert_difference(["User.count", "Entry.count"], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "New Requester",
|
||||
email: "newrequester@example.com",
|
||||
category: "word",
|
||||
fi: "uusi sana",
|
||||
en: "new word",
|
||||
notes: "Please add this word"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
user = User.last
|
||||
|
||||
assert_equal "requested", entry.status
|
||||
assert_equal user, entry.requested_by
|
||||
assert_equal "New Requester", user.name
|
||||
assert_equal "newrequester@example.com", user.email
|
||||
assert_equal "contributor", user.role
|
||||
assert_nil user.invitation_token
|
||||
assert_redirected_to root_path
|
||||
assert_match(/thank you for your request/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should require at least one translation" do
|
||||
assert_no_difference(["User.count", "Entry.count"]) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "New Requester",
|
||||
email: "newrequester@example.com",
|
||||
category: "word",
|
||||
notes: "No translations provided"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select ".bg-red-50", text: /At least one language translation is required/
|
||||
end
|
||||
|
||||
test "should redirect to login if email already exists" do
|
||||
existing_user = users(:contributor_user)
|
||||
|
||||
assert_no_difference(["User.count", "Entry.count"]) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Test User",
|
||||
email: existing_user.email,
|
||||
category: "word",
|
||||
fi: "sana",
|
||||
en: "word"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to login_path
|
||||
assert_equal "An account with this email already exists. Please log in.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should show pending count for email with existing requests" do
|
||||
requester = users(:requester_user)
|
||||
get new_request_path, params: { email: requester.email }
|
||||
|
||||
assert_response :success
|
||||
# User has one requested entry from fixtures
|
||||
assert_select ".bg-blue-50", text: /1 pending request/
|
||||
end
|
||||
|
||||
test "should create entry with only one translation" do
|
||||
assert_difference(["User.count", "Entry.count"], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Single Translation",
|
||||
email: "single@example.com",
|
||||
category: "word",
|
||||
fi: "vain suomeksi"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal "vain suomeksi", entry.fi
|
||||
assert_nil entry.en
|
||||
assert_nil entry.sv
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should create entry with multiple translations" do
|
||||
assert_difference(["User.count", "Entry.count"], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Multi Lingual",
|
||||
email: "multilingual@example.com",
|
||||
category: "phrase",
|
||||
fi: "hyvää päivää",
|
||||
en: "good day",
|
||||
sv: "god dag",
|
||||
no: "god dag",
|
||||
ru: "добрый день",
|
||||
de: "guten Tag"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal "hyvää päivää", entry.fi
|
||||
assert_equal "good day", entry.en
|
||||
assert_equal "god dag", entry.sv
|
||||
assert_equal "god dag", entry.no
|
||||
assert_equal "добрый день", entry.ru
|
||||
assert_equal "guten Tag", entry.de
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "logged-in user can submit request without providing name/email" do
|
||||
user = users(:contributor_user)
|
||||
login_as(user)
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
assert_difference("Entry.count", 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
category: "word",
|
||||
fi: "kirjautunut käyttäjä",
|
||||
en: "logged in user"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal user, entry.requested_by
|
||||
assert_equal "requested", entry.status
|
||||
assert_redirected_to root_path
|
||||
assert_match(/thank you for your request/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should not modify existing user when they submit request" do
|
||||
user = users(:contributor_user)
|
||||
original_name = user.name
|
||||
original_updated_at = user.updated_at
|
||||
login_as(user)
|
||||
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
category: "word",
|
||||
fi: "testi"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_equal original_name, user.name
|
||||
assert_equal original_updated_at.to_i, user.updated_at.to_i
|
||||
end
|
||||
|
||||
test "should reuse existing pending user without modifying them" do
|
||||
# Create a user without accepted invitation
|
||||
existing_user = User.create!(
|
||||
name: "Pending User",
|
||||
email: "pending_test@example.com",
|
||||
password: SecureRandom.alphanumeric(32),
|
||||
role: :contributor
|
||||
)
|
||||
original_name = existing_user.name
|
||||
original_updated_at = existing_user.updated_at
|
||||
|
||||
# Create first entry
|
||||
Entry.create!(
|
||||
category: :word,
|
||||
fi: "first",
|
||||
status: :requested,
|
||||
requested_by: existing_user
|
||||
)
|
||||
|
||||
# Submit second request with same email but different name
|
||||
assert_no_difference("User.count") do
|
||||
assert_difference("Entry.count", 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Different Name", # This should be ignored
|
||||
email: existing_user.email,
|
||||
category: "word",
|
||||
fi: "second"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
existing_user.reload
|
||||
assert_equal original_name, existing_user.name # Name should not change
|
||||
assert_equal original_updated_at.to_i, existing_user.updated_at.to_i # Should not be updated
|
||||
assert_equal 2, existing_user.requested_entries.count
|
||||
end
|
||||
end
|
||||
Vendored
+32
@@ -12,6 +12,7 @@ one:
|
||||
verified: false
|
||||
created_by: admin_user
|
||||
updated_by: admin_user
|
||||
status: 2 # active
|
||||
|
||||
two:
|
||||
category: 1
|
||||
@@ -25,3 +26,34 @@ two:
|
||||
verified: false
|
||||
created_by: contributor_user
|
||||
updated_by: contributor_user
|
||||
status: 2 # active
|
||||
|
||||
requested_entry:
|
||||
category: 0 # word
|
||||
fi: "testisana"
|
||||
en: "testword"
|
||||
sv: ~
|
||||
'no': ~
|
||||
ru: ~
|
||||
de: ~
|
||||
notes: "This is a test entry request"
|
||||
verified: false
|
||||
created_by: ~
|
||||
updated_by: ~
|
||||
status: 0 # requested
|
||||
requested_by: requester_user
|
||||
|
||||
approved_entry:
|
||||
category: 1 # phrase
|
||||
fi: "hyväksytty fraasi"
|
||||
en: "approved phrase"
|
||||
sv: ~
|
||||
'no': ~
|
||||
ru: ~
|
||||
de: ~
|
||||
notes: "This entry has been approved"
|
||||
verified: false
|
||||
created_by: ~
|
||||
updated_by: ~
|
||||
status: 1 # approved
|
||||
requested_by: pending_invitation
|
||||
|
||||
Vendored
+11
@@ -44,3 +44,14 @@ pending_invitation:
|
||||
invitation_sent_at: <%= 2.days.ago %>
|
||||
invitation_accepted_at: ~
|
||||
invited_by: admin_user
|
||||
|
||||
requester_user:
|
||||
email: "requester@example.com"
|
||||
password_digest: <%= BCrypt::Password.create('password123456') %>
|
||||
name: "Entry Requester"
|
||||
role: 0 # contributor
|
||||
primary_language: "en"
|
||||
invitation_token: ~
|
||||
invitation_sent_at: ~
|
||||
invitation_accepted_at: ~
|
||||
invited_by: ~
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
require "test_helper"
|
||||
|
||||
class EntryRequestFlowTest < ActionDispatch::IntegrationTest
|
||||
test "complete flow: request -> admin approve -> user accepts -> entry active" do
|
||||
# Step 1: Public user submits entry request
|
||||
assert_difference(["User.count", "Entry.count"], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Flow Test User",
|
||||
email: "flowtest@example.com",
|
||||
category: "word",
|
||||
fi: "testi",
|
||||
en: "test",
|
||||
notes: "Testing complete flow"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
|
||||
entry = Entry.last
|
||||
user = User.last
|
||||
|
||||
assert_equal "requested", entry.status
|
||||
assert_equal user, entry.requested_by
|
||||
assert_nil user.invitation_token
|
||||
|
||||
# Step 2: Admin reviews and approves request
|
||||
admin = users(:admin_user)
|
||||
login_as(admin)
|
||||
|
||||
get admin_requests_path
|
||||
assert_response :success
|
||||
assert_select "td", text: entry.fi
|
||||
|
||||
# View the request
|
||||
get admin_request_path(entry)
|
||||
assert_response :success
|
||||
assert_select "div", text: entry.fi
|
||||
|
||||
# Approve the request
|
||||
assert_enqueued_emails 1 do
|
||||
post approve_admin_request_path(entry)
|
||||
end
|
||||
|
||||
entry.reload
|
||||
user.reload
|
||||
|
||||
assert_equal "approved", entry.status
|
||||
assert_not_nil user.invitation_token
|
||||
assert_not_nil user.invitation_sent_at
|
||||
assert_equal admin, user.invited_by
|
||||
|
||||
# Verify the invitation email includes entry information
|
||||
perform_enqueued_jobs
|
||||
sent_email = ActionMailer::Base.deliveries.last
|
||||
assert_match "Your entry request has been approved", sent_email.subject
|
||||
assert_match entry.fi, sent_email.body.encoded
|
||||
assert_match entry.en, sent_email.body.encoded
|
||||
|
||||
logout
|
||||
|
||||
# Step 3: User accepts invitation
|
||||
get invitation_path(user.invitation_token)
|
||||
assert_response :success
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
entry.reload
|
||||
user.reload
|
||||
|
||||
# Entry should now be active
|
||||
assert_equal "active", entry.status
|
||||
assert_not_nil user.invitation_accepted_at
|
||||
assert_nil user.invitation_token
|
||||
assert_equal user.id, session[:user_id]
|
||||
|
||||
# Step 4: Verify entry is visible on public site
|
||||
get root_path
|
||||
assert_response :success
|
||||
# Entry should be in search results
|
||||
end
|
||||
|
||||
test "rejected request removes entry and user" do
|
||||
# Create a request
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "To Be Rejected",
|
||||
email: "rejected@example.com",
|
||||
category: "word",
|
||||
fi: "hylätty",
|
||||
en: "rejected"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
|
||||
entry = Entry.last
|
||||
user = User.last
|
||||
entry_id = entry.id
|
||||
user_id = user.id
|
||||
|
||||
# Admin rejects it
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_difference(["User.count", "Entry.count"], -1) do
|
||||
delete reject_admin_request_path(entry)
|
||||
end
|
||||
|
||||
assert_not Entry.exists?(entry_id)
|
||||
assert_not User.exists?(user_id)
|
||||
end
|
||||
|
||||
test "requested and approved entries not visible on public site" do
|
||||
requested = entries(:requested_entry)
|
||||
approved = entries(:approved_entry)
|
||||
active = entries(:one)
|
||||
|
||||
get root_path
|
||||
assert_response :success
|
||||
|
||||
# Active entry should be counted
|
||||
assert_match /#{Entry.active_entries.count}/, response.body
|
||||
|
||||
# Verify counts exclude requested/approved entries
|
||||
total_entries = Entry.count
|
||||
active_entries = Entry.active_entries.count
|
||||
|
||||
assert total_entries > active_entries, "Should have non-active entries in fixtures"
|
||||
end
|
||||
|
||||
test "multiple entries by same requester all activated on invitation acceptance" do
|
||||
# Create user with multiple approved entries
|
||||
user = User.create!(
|
||||
name: "Multi Entry User",
|
||||
email: "multi@example.com",
|
||||
password: SecureRandom.alphanumeric(32),
|
||||
role: :contributor,
|
||||
invitation_token: "multi_token_123",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invited_by: users(:admin_user)
|
||||
)
|
||||
|
||||
entry1 = Entry.create!(
|
||||
category: :word,
|
||||
fi: "sana1",
|
||||
en: "word1",
|
||||
status: :approved,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
entry2 = Entry.create!(
|
||||
category: :phrase,
|
||||
fi: "fraasi1",
|
||||
en: "phrase1",
|
||||
status: :approved,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
# User accepts invitation
|
||||
patch accept_invitation_path("multi_token_123"), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
entry1.reload
|
||||
entry2.reload
|
||||
|
||||
assert_equal "active", entry1.status
|
||||
assert_equal "active", entry2.status
|
||||
end
|
||||
|
||||
test "admin can edit entry details before approval" do
|
||||
# Create a request
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Needs Editing",
|
||||
email: "edit@example.com",
|
||||
category: "word",
|
||||
fi: "väärin kirjoitettu",
|
||||
en: "wrong spelling"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
|
||||
entry = Entry.last
|
||||
|
||||
# Admin edits the entry
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get edit_admin_request_path(entry)
|
||||
assert_response :success
|
||||
|
||||
patch admin_request_path(entry), params: {
|
||||
entry: {
|
||||
fi: "oikein kirjoitettu",
|
||||
en: "correct spelling",
|
||||
category: "phrase"
|
||||
}
|
||||
}
|
||||
|
||||
entry.reload
|
||||
assert_equal "oikein kirjoitettu", entry.fi
|
||||
assert_equal "correct spelling", entry.en
|
||||
assert_equal "phrase", entry.category
|
||||
|
||||
# Then approve with corrected details
|
||||
post approve_admin_request_path(entry)
|
||||
|
||||
entry.reload
|
||||
assert_equal "approved", entry.status
|
||||
assert_equal "oikein kirjoitettu", entry.fi
|
||||
end
|
||||
|
||||
test "cannot submit request with existing user email" do
|
||||
existing_user = users(:contributor_user)
|
||||
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Test",
|
||||
email: existing_user.email,
|
||||
category: "word",
|
||||
fi: "test"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to login_path
|
||||
assert_match(/already exists/i, flash[:alert])
|
||||
end
|
||||
end
|
||||
@@ -35,4 +35,50 @@ class InvitationMailerTest < ActionMailer::TestCase
|
||||
assert_equal "text/plain", mail.text_part.content_type.split(";").first
|
||||
assert_equal "text/html", mail.html_part.content_type.split(";").first
|
||||
end
|
||||
|
||||
test "invite with approved_entry includes entry details" do
|
||||
user = users(:requester_user)
|
||||
user.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current
|
||||
)
|
||||
entry = entries(:requested_entry)
|
||||
mail = InvitationMailer.invite(user, approved_entry: entry)
|
||||
|
||||
assert_equal "Your entry request has been approved - Join Sanasto Wiki", mail.subject
|
||||
assert_equal [ user.email ], mail.to
|
||||
|
||||
# Check that entry details are included
|
||||
assert_match entry.fi, mail.body.encoded
|
||||
assert_match entry.en, mail.body.encoded
|
||||
assert_match entry.category.to_s.humanize, mail.body.encoded
|
||||
assert_match "approved", mail.body.encoded.downcase
|
||||
end
|
||||
|
||||
test "invite with approved_entry shows correct message" do
|
||||
user = users(:requester_user)
|
||||
user.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current
|
||||
)
|
||||
entry = entries(:requested_entry)
|
||||
mail = InvitationMailer.invite(user, approved_entry: entry)
|
||||
|
||||
# HTML part should contain the entry box
|
||||
assert_match "Your Approved Entry", mail.html_part.body.encoded
|
||||
assert_match "entry-box", mail.html_part.body.encoded
|
||||
|
||||
# Text part should contain entry details
|
||||
assert_match "YOUR APPROVED ENTRY", mail.text_part.body.encoded
|
||||
assert_match "Category:", mail.text_part.body.encoded
|
||||
end
|
||||
|
||||
test "invite without approved_entry uses standard message" do
|
||||
user = users(:pending_invitation)
|
||||
mail = InvitationMailer.invite(user)
|
||||
|
||||
assert_equal "You've been invited to join Sanasto Wiki", mail.subject
|
||||
assert_match "you can contribute to this work", mail.body.encoded.downcase
|
||||
assert_no_match "approved", mail.body.encoded.downcase
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
require "test_helper"
|
||||
|
||||
class EntryRequestTest < ActiveSupport::TestCase
|
||||
test "entry requires at least one translation" do
|
||||
entry = Entry.new(
|
||||
category: :word,
|
||||
status: :requested
|
||||
)
|
||||
|
||||
assert_not entry.valid?
|
||||
assert_includes entry.errors[:base], "At least one language translation is required"
|
||||
end
|
||||
|
||||
test "entry is valid with one translation" do
|
||||
entry = Entry.new(
|
||||
category: :word,
|
||||
fi: "sana",
|
||||
status: :requested
|
||||
)
|
||||
|
||||
assert entry.valid?
|
||||
end
|
||||
|
||||
test "entry is valid with multiple translations" do
|
||||
entry = Entry.new(
|
||||
category: :word,
|
||||
fi: "sana",
|
||||
en: "word",
|
||||
sv: "ord",
|
||||
status: :requested
|
||||
)
|
||||
|
||||
assert entry.valid?
|
||||
end
|
||||
|
||||
test "entry status defaults to active" do
|
||||
entry = Entry.new(
|
||||
category: :word,
|
||||
fi: "test"
|
||||
)
|
||||
|
||||
assert_equal "active", entry.status
|
||||
end
|
||||
|
||||
test "requested scope returns only requested entries" do
|
||||
requested_entries = Entry.requested
|
||||
assert_includes requested_entries, entries(:requested_entry)
|
||||
assert_not_includes requested_entries, entries(:one)
|
||||
assert_not_includes requested_entries, entries(:approved_entry)
|
||||
end
|
||||
|
||||
test "approved scope returns only approved entries" do
|
||||
approved_entries = Entry.approved
|
||||
assert_includes approved_entries, entries(:approved_entry)
|
||||
assert_not_includes approved_entries, entries(:one)
|
||||
assert_not_includes approved_entries, entries(:requested_entry)
|
||||
end
|
||||
|
||||
test "active_entries scope returns only active entries" do
|
||||
active_entries = Entry.active_entries
|
||||
assert_includes active_entries, entries(:one)
|
||||
assert_includes active_entries, entries(:two)
|
||||
assert_not_includes active_entries, entries(:requested_entry)
|
||||
assert_not_includes active_entries, entries(:approved_entry)
|
||||
end
|
||||
|
||||
test "requested_by association" do
|
||||
entry = entries(:requested_entry)
|
||||
requester = users(:requester_user)
|
||||
|
||||
assert_equal requester, entry.requested_by
|
||||
end
|
||||
|
||||
test "user has requested_entries association" do
|
||||
requester = users(:requester_user)
|
||||
|
||||
assert_includes requester.requested_entries, entries(:requested_entry)
|
||||
end
|
||||
|
||||
test "can create entry with requested status" do
|
||||
user = users(:requester_user)
|
||||
entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "uusi",
|
||||
en: "new",
|
||||
status: :requested,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
assert_equal "requested", entry.status
|
||||
assert_equal user, entry.requested_by
|
||||
end
|
||||
|
||||
test "can transition entry from requested to approved to active" do
|
||||
entry = entries(:requested_entry)
|
||||
|
||||
assert_equal "requested", entry.status
|
||||
|
||||
entry.update!(status: :approved)
|
||||
assert_equal "approved", entry.status
|
||||
|
||||
entry.update!(status: :active)
|
||||
assert_equal "active", entry.status
|
||||
end
|
||||
|
||||
test "entries count does not include requested or approved" do
|
||||
total = Entry.count
|
||||
active = Entry.active_entries.count
|
||||
requested = Entry.requested.count
|
||||
approved = Entry.approved.count
|
||||
|
||||
assert_equal total, active + requested + approved
|
||||
assert requested > 0, "Should have at least one requested entry in fixtures"
|
||||
assert approved > 0, "Should have at least one approved entry in fixtures"
|
||||
end
|
||||
|
||||
test "blank translations are not counted as having translation" do
|
||||
entry = Entry.new(
|
||||
category: :word,
|
||||
fi: "",
|
||||
en: " ",
|
||||
sv: nil
|
||||
)
|
||||
|
||||
assert_not entry.valid?
|
||||
assert_includes entry.errors[:base], "At least one language translation is required"
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
require "test_helper"
|
||||
|
||||
class EntryTest < ActiveSupport::TestCase
|
||||
test "should be valid with a category" do
|
||||
entry = Entry.new(category: :word)
|
||||
test "should be valid with a category and at least one translation" do
|
||||
entry = Entry.new(category: :word, fi: "test")
|
||||
assert entry.valid?
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user