Compare commits
4 Commits
887d52c447
...
530021960e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530021960e | ||
|
|
b64ad52d30 | ||
|
|
4a6388ade6 | ||
|
|
e7f2215be4 |
@@ -1,53 +0,0 @@
|
|||||||
# Sanasto Wiki TODO List
|
|
||||||
|
|
||||||
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High Priority
|
|
||||||
|
|
||||||
### Bugs
|
|
||||||
|
|
||||||
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
|
|
||||||
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
|
|
||||||
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medium Priority
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
|
|
||||||
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
|
|
||||||
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
|
|
||||||
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
|
|
||||||
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
|
|
||||||
|
|
||||||
### Discussion
|
|
||||||
|
|
||||||
- [x] **Add comments to entries**: Users can now add comments to entries.
|
|
||||||
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
|
|
||||||
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
|
|
||||||
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Low Priority
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
|
|
||||||
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
|
|
||||||
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
|
|||||||
@pending_suggestions_count = SuggestedMeaning.pending.count
|
@pending_suggestions_count = SuggestedMeaning.pending.count
|
||||||
@accepted_suggestions_count = SuggestedMeaning.accepted.count
|
@accepted_suggestions_count = SuggestedMeaning.accepted.count
|
||||||
@rejected_suggestions_count = SuggestedMeaning.rejected.count
|
@rejected_suggestions_count = SuggestedMeaning.rejected.count
|
||||||
|
@requested_entries_count = Entry.requested.count
|
||||||
|
|
||||||
@comment_count = Comment.count
|
@comment_count = Comment.count
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ class Admin::InvitationsController < Admin::BaseController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resend
|
||||||
|
@invitation = User.find(params[:id])
|
||||||
|
|
||||||
|
if @invitation.invitation_accepted_at.present?
|
||||||
|
redirect_to admin_invitations_path, alert: "Cannot resend an accepted invitation."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@invitation.update!(
|
||||||
|
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||||
|
invitation_sent_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
InvitationMailer.invite(@invitation).deliver_later
|
||||||
|
|
||||||
|
redirect_to admin_invitations_path, notice: "Invitation resent to #{@invitation.email}"
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@invitation = User.find(params[:id])
|
@invitation = User.find(params[:id])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@page = [ params[:page].to_i, 1 ].max
|
||||||
@per_page = 25
|
@per_page = 25
|
||||||
|
|
||||||
entries_scope = Entry.all
|
entries_scope = Entry.active_entries
|
||||||
entries_scope = entries_scope.with_category(@category)
|
entries_scope = entries_scope.with_category(@category)
|
||||||
entries_scope = entries_scope.search(@query, language_code: @language_code)
|
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?
|
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
|
@total_pages = (@total_entries.to_f / @per_page).ceil
|
||||||
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
|
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
|
||||||
|
|
||||||
@entry_count = Entry.count
|
@entry_count = Entry.active_entries.count
|
||||||
@verified_count = Entry.where(verified: true).count
|
@requested_count = Entry.requested.count
|
||||||
|
@verified_count = Entry.active_entries.where(verified: true).count
|
||||||
@needs_review_count = @entry_count - @verified_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, "" ])
|
scope.where.not(language.code => [ nil, "" ])
|
||||||
end.count
|
end.count
|
||||||
@missing_entries_count = @entry_count - @complete_entries_count
|
@missing_entries_count = @entry_count - @complete_entries_count
|
||||||
@language_completion = supported_languages.index_with do |language|
|
@language_completion = supported_languages.index_with do |language|
|
||||||
next 0 if @entry_count.zero?
|
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
|
end
|
||||||
|
|
||||||
if @language_code.present?
|
if @language_code.present?
|
||||||
@@ -61,7 +62,7 @@ class EntriesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def download
|
def download
|
||||||
@entries = Entry.order(:id)
|
@entries = Entry.active_entries.order(:id)
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.xlsx do
|
format.xlsx do
|
||||||
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
|
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
|
|||||||
invitation_token: nil
|
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
|
session[:user_id] = @user.id
|
||||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
||||||
else
|
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)
|
def format_entry_category(entry)
|
||||||
entry.category.to_s.tr("_", " ").capitalize
|
entry.category.to_s.tr("_", " ").capitalize
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
class InvitationMailer < ApplicationMailer
|
class InvitationMailer < ApplicationMailer
|
||||||
def invite(user)
|
def invite(user, approved_entry: nil)
|
||||||
@user = user
|
@user = user
|
||||||
|
@approved_entry = approved_entry
|
||||||
@invitation_url = invitation_url(@user.invitation_token)
|
@invitation_url = invitation_url(@user.invitation_token)
|
||||||
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
@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(
|
mail(
|
||||||
to: @user.email,
|
to: @user.email,
|
||||||
subject: "You've been invited to join Sanasto Wiki"
|
subject: subject
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
class Entry < ApplicationRecord
|
class Entry < ApplicationRecord
|
||||||
belongs_to :created_by, class_name: "User", optional: true
|
belongs_to :created_by, class_name: "User", optional: true
|
||||||
belongs_to :updated_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 :suggested_meanings, dependent: :destroy
|
||||||
has_many :comments, as: :commentable, dependent: :destroy
|
has_many :comments, as: :commentable, dependent: :destroy
|
||||||
|
|
||||||
enum :category, %i[word phrase proper_name title reference other]
|
enum :category, %i[word phrase proper_name title reference other]
|
||||||
|
enum :status, %i[requested approved active], default: :active
|
||||||
|
|
||||||
validates :category, presence: true
|
validates :category, presence: true
|
||||||
|
validate :at_least_one_translation
|
||||||
|
|
||||||
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
|
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)
|
def self.search(query, language_code: nil)
|
||||||
return all if query.blank?
|
return all if query.blank?
|
||||||
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
|
|||||||
def self.valid_lang?(code)
|
def self.valid_lang?(code)
|
||||||
SupportedLanguage.valid_codes.include?(code.to_s)
|
SupportedLanguage.valid_codes.include?(code.to_s)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
|
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 :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,
|
has_many :submitted_suggested_meanings,
|
||||||
class_name: "SuggestedMeaning",
|
class_name: "SuggestedMeaning",
|
||||||
foreign_key: :submitted_by_id,
|
foreign_key: :submitted_by_id,
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<%= link_to admin_users_path do %>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
<dl>
|
||||||
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||||
</dl>
|
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,10 +46,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<%= link_to root_path do %>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
<dl>
|
||||||
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
||||||
</dl>
|
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,10 +73,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<%= link_to admin_requests_path do %>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
|
<dl>
|
||||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
|
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
|
||||||
</dl>
|
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,10 +100,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<%= link_to admin_invitations_path do %>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
<dl>
|
||||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
||||||
</dl>
|
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,8 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
|
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-4">
|
||||||
|
<%= button_to "Re-send", resend_admin_invitation_path(invitation), method: :put, class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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>
|
<tr>
|
||||||
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
||||||
No entries matched your filters.
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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),
|
<%= 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" %>
|
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? %>
|
<% if admin? %>
|
||||||
@@ -21,6 +23,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="flex-1 flex flex-col">
|
||||||
<section id="search">
|
<section id="search">
|
||||||
|
|||||||
@@ -79,6 +79,32 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 4px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -90,11 +116,53 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||||
|
|
||||||
<p>
|
<% if @approved_entry %>
|
||||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
<p>
|
||||||
</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">
|
<div class="info-box">
|
||||||
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
||||||
|
|||||||
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
|
|||||||
|
|
||||||
Hello <%= @user.name %>,
|
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.
|
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.
|
With a login account, you can contribute to this work.
|
||||||
|
<% end %>
|
||||||
|
|
||||||
YOUR ACCOUNT DETAILS
|
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 "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 "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" %>
|
<%= 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" %>
|
<%= 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" %>
|
<%= 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>
|
</nav>
|
||||||
@@ -35,8 +45,13 @@
|
|||||||
<% if flash.any? %>
|
<% if flash.any? %>
|
||||||
<div class="max-w-7xl mx-auto px-4 mt-4">
|
<div class="max-w-7xl mx-auto px-4 mt-4">
|
||||||
<% flash.each do |type, message| %>
|
<% 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">
|
<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"><%= message %></span>
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||||
|
|
||||||
<link rel="icon" href="/icon.png" type="image/png">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="icon" href="/icon.png" type="image/png">
|
||||||
<link rel="apple-touch-icon" href="/icon.png">
|
<link rel="apple-touch-icon" href="/icon.png">
|
||||||
|
|
||||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||||
|
|||||||
@@ -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>
|
||||||
+14
-1
@@ -25,12 +25,25 @@ Rails.application.routes.draw do
|
|||||||
get "invitations/:token", to: "invitations#show", as: :invitation
|
get "invitations/:token", to: "invitations#show", as: :invitation
|
||||||
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
|
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
|
||||||
|
|
||||||
|
# Public entry request routes
|
||||||
|
resources :requests, only: [:new, :create]
|
||||||
|
|
||||||
# Admin namespace
|
# Admin namespace
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
get "dashboard", to: "dashboard#index"
|
get "dashboard", to: "dashboard#index"
|
||||||
resources :users, only: [ :index, :edit, :update, :destroy ]
|
resources :users, only: [ :index, :edit, :update, :destroy ]
|
||||||
resources :invitations, only: [ :index, :new, :create, :destroy ]
|
resources :invitations, only: [ :index, :new, :create, :destroy ] do
|
||||||
|
member do
|
||||||
|
put :resend
|
||||||
|
end
|
||||||
|
end
|
||||||
|
resources :requests, only: [ :index, :show, :edit, :update ] do
|
||||||
|
member do
|
||||||
|
post :approve
|
||||||
|
delete :reject
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :entries do
|
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 "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 "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"
|
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")
|
FOREIGN KEY ("user_id")
|
||||||
REFERENCES "users" ("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_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_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||||
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
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 "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
|
INSERT INTO "schema_migrations" (version) VALUES
|
||||||
|
('20260129204706'),
|
||||||
|
('20260129204705'),
|
||||||
('20260123130957'),
|
('20260123130957'),
|
||||||
('20260123125325'),
|
('20260123125325'),
|
||||||
('20260122131000'),
|
('20260122131000'),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 23 KiB |
+45
-2
@@ -1,3 +1,46 @@
|
|||||||
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<circle cx="256" cy="256" r="256" fill="red"/>
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="icon.svg"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
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="svg3" />
|
||||||
|
<!-- Background -->
|
||||||
|
<rect
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
fill="#4f46e5"
|
||||||
|
rx="64"
|
||||||
|
id="rect1" />
|
||||||
|
<!-- 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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 122 B 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
|
verified: false
|
||||||
created_by: admin_user
|
created_by: admin_user
|
||||||
updated_by: admin_user
|
updated_by: admin_user
|
||||||
|
status: 2 # active
|
||||||
|
|
||||||
two:
|
two:
|
||||||
category: 1
|
category: 1
|
||||||
@@ -25,3 +26,34 @@ two:
|
|||||||
verified: false
|
verified: false
|
||||||
created_by: contributor_user
|
created_by: contributor_user
|
||||||
updated_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_sent_at: <%= 2.days.ago %>
|
||||||
invitation_accepted_at: ~
|
invitation_accepted_at: ~
|
||||||
invited_by: admin_user
|
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/plain", mail.text_part.content_type.split(";").first
|
||||||
assert_equal "text/html", mail.html_part.content_type.split(";").first
|
assert_equal "text/html", mail.html_part.content_type.split(";").first
|
||||||
end
|
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
|
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"
|
require "test_helper"
|
||||||
|
|
||||||
class EntryTest < ActiveSupport::TestCase
|
class EntryTest < ActiveSupport::TestCase
|
||||||
test "should be valid with a category" do
|
test "should be valid with a category and at least one translation" do
|
||||||
entry = Entry.new(category: :word)
|
entry = Entry.new(category: :word, fi: "test")
|
||||||
assert entry.valid?
|
assert entry.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user