feat: Category API to handle linked categories and parent-sub categories (#4879)
This commit is contained in:
parent
c0249a1b5b
commit
df1bf112ea
15 changed files with 286 additions and 29 deletions
|
@ -8,10 +8,20 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||
|
||||
def create
|
||||
@category = @portal.categories.create!(category_params)
|
||||
@category.related_categories << related_categories_records
|
||||
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||
|
||||
@category.save!
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@category.update!(category_params)
|
||||
@category.related_categories = related_categories_records if related_categories_records.any?
|
||||
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
|
||||
|
||||
@category.save!
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -29,9 +39,13 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
|||
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def related_categories_records
|
||||
@portal.categories.where(id: params[:category][:related_category_ids])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(
|
||||
:name, :description, :position, :slug, :locale
|
||||
:name, :description, :position, :slug, :locale, :parent_category_id, :linked_category_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,28 +2,56 @@
|
|||
#
|
||||
# Table name: categories
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# locale :string default("en")
|
||||
# name :string
|
||||
# position :integer
|
||||
# slug :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# portal_id :integer not null
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# locale :string default("en")
|
||||
# name :string
|
||||
# position :integer
|
||||
# slug :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# linked_category_id :bigint
|
||||
# parent_category_id :bigint
|
||||
# portal_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_categories_on_linked_category_id (linked_category_id)
|
||||
# index_categories_on_locale (locale)
|
||||
# index_categories_on_locale_and_account_id (locale,account_id)
|
||||
# index_categories_on_parent_category_id (parent_category_id)
|
||||
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (linked_category_id => categories.id)
|
||||
# fk_rails_... (parent_category_id => categories.id)
|
||||
#
|
||||
class Category < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :portal
|
||||
has_many :folders, dependent: :destroy_async
|
||||
has_many :articles, dependent: :nullify
|
||||
has_many :category_related_categories,
|
||||
class_name: :RelatedCategory,
|
||||
dependent: :destroy_async
|
||||
has_many :related_categories,
|
||||
through: :category_related_categories,
|
||||
class_name: :Category,
|
||||
dependent: :nullify
|
||||
has_many :sub_categories,
|
||||
class_name: :Category,
|
||||
foreign_key: :parent_category_id,
|
||||
dependent: :nullify,
|
||||
inverse_of: 'parent_category'
|
||||
has_many :linked_categories,
|
||||
class_name: :Category,
|
||||
foreign_key: :linked_category_id,
|
||||
dependent: :nullify,
|
||||
inverse_of: 'linked_category'
|
||||
belongs_to :parent_category, class_name: :Category, optional: true
|
||||
belongs_to :linked_category, class_name: :Category, optional: true
|
||||
|
||||
before_validation :ensure_account_id
|
||||
validates :account_id, presence: true
|
||||
|
@ -31,6 +59,7 @@ class Category < ApplicationRecord
|
|||
validates :name, presence: true
|
||||
validates :locale, uniqueness: { scope: %i[slug portal_id],
|
||||
message: 'should be unique in the category and portal' }
|
||||
accepts_nested_attributes_for :related_categories
|
||||
|
||||
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
|
||||
|
||||
|
|
19
app/models/related_category.rb
Normal file
19
app/models/related_category.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
# == Schema Information
|
||||
#
|
||||
# Table name: related_categories
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# category_id :bigint
|
||||
# related_category_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_related_categories_on_category_id_and_related_category_id (category_id,related_category_id) UNIQUE
|
||||
# index_related_categories_on_related_category_id_and_category_id (related_category_id,category_id) UNIQUE
|
||||
#
|
||||
class RelatedCategory < ApplicationRecord
|
||||
belongs_to :related_category, class_name: 'Category'
|
||||
belongs_to :category, class_name: 'Category'
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
json.id category.id
|
||||
json.name category.name
|
||||
json.slug category.slug
|
||||
json.locale category.locale
|
||||
json.description category.description
|
||||
json.position category.position
|
||||
json.account_id category.account_id
|
|
@ -5,3 +5,23 @@ json.locale category.locale
|
|||
json.description category.description
|
||||
json.position category.position
|
||||
json.account_id category.account_id
|
||||
|
||||
json.related_categories do
|
||||
if category.related_categories.any?
|
||||
json.array! category.related_categories.each do |related_category|
|
||||
json.partial! 'api/v1/accounts/categories/associated_category.json.jbuilder', category: related_category
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if category.parent_category.present?
|
||||
json.parent_category do
|
||||
json.partial! 'api/v1/accounts/categories/associated_category.json.jbuilder', category: category.parent_category
|
||||
end
|
||||
end
|
||||
|
||||
if category.linked_category.present?
|
||||
json.linked_category do
|
||||
json.partial! 'api/v1/accounts/categories/associated_category.json.jbuilder', category: category.linked_category
|
||||
end
|
||||
end
|
||||
|
|
3
app/views/api/v1/accounts/categories/show.json.jbuilder
Normal file
3
app/views/api/v1/accounts/categories/show.json.jbuilder
Normal file
|
@ -0,0 +1,3 @@
|
|||
json.payload do
|
||||
json.partial! 'category', category: @category
|
||||
end
|
|
@ -159,9 +159,7 @@ Rails.application.routes.draw do
|
|||
member do
|
||||
post :archive
|
||||
end
|
||||
resources :categories do
|
||||
resources :folders
|
||||
end
|
||||
resources :categories
|
||||
resources :articles
|
||||
end
|
||||
end
|
||||
|
|
5
db/migrate/20220623113405_add_parent_id_to_category.rb
Normal file
5
db/migrate/20220623113405_add_parent_id_to_category.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddParentIdToCategory < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_reference :categories, :parent_category, foreign_key: { to_table: :categories }
|
||||
end
|
||||
end
|
12
db/migrate/20220623113604_create_related_categories.rb
Normal file
12
db/migrate/20220623113604_create_related_categories.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
class CreateRelatedCategories < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :related_categories do |t|
|
||||
t.bigint :category_id
|
||||
t.bigint :related_category_id
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :related_categories, [:category_id, :related_category_id], unique: true
|
||||
add_index :related_categories, [:related_category_id, :category_id], unique: true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddLinkedCategoryIdToCategories < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_reference :categories, :linked_category, foreign_key: { to_table: :categories }
|
||||
end
|
||||
end
|
17
db/schema.rb
17
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_06_22_090344) do
|
||||
ActiveRecord::Schema.define(version: 2022_06_27_135753) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
|
@ -197,8 +197,12 @@ ActiveRecord::Schema.define(version: 2022_06_22_090344) do
|
|||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "locale", default: "en"
|
||||
t.string "slug", null: false
|
||||
t.bigint "parent_category_id"
|
||||
t.bigint "linked_category_id"
|
||||
t.index ["linked_category_id"], name: "index_categories_on_linked_category_id"
|
||||
t.index ["locale", "account_id"], name: "index_categories_on_locale_and_account_id"
|
||||
t.index ["locale"], name: "index_categories_on_locale"
|
||||
t.index ["parent_category_id"], name: "index_categories_on_parent_category_id"
|
||||
t.index ["slug", "locale", "portal_id"], name: "index_categories_on_slug_and_locale_and_portal_id", unique: true
|
||||
end
|
||||
|
||||
|
@ -683,6 +687,15 @@ ActiveRecord::Schema.define(version: 2022_06_22_090344) do
|
|||
t.index ["user_id"], name: "index_portals_members_on_user_id"
|
||||
end
|
||||
|
||||
create_table "related_categories", force: :cascade do |t|
|
||||
t.bigint "category_id"
|
||||
t.bigint "related_category_id"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["category_id", "related_category_id"], name: "index_related_categories_on_category_id_and_related_category_id", unique: true
|
||||
t.index ["related_category_id", "category_id"], name: "index_related_categories_on_related_category_id_and_category_id", unique: true
|
||||
end
|
||||
|
||||
create_table "reporting_events", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.float "value"
|
||||
|
@ -826,6 +839,8 @@ ActiveRecord::Schema.define(version: 2022_06_22_090344) do
|
|||
add_foreign_key "articles", "users", column: "author_id"
|
||||
add_foreign_key "campaigns", "accounts", on_delete: :cascade
|
||||
add_foreign_key "campaigns", "inboxes", on_delete: :cascade
|
||||
add_foreign_key "categories", "categories", column: "linked_category_id"
|
||||
add_foreign_key "categories", "categories", column: "parent_category_id"
|
||||
add_foreign_key "contact_inboxes", "contacts", on_delete: :cascade
|
||||
add_foreign_key "contact_inboxes", "inboxes", on_delete: :cascade
|
||||
add_foreign_key "conversations", "campaigns", on_delete: :cascade
|
||||
|
|
|
@ -5,6 +5,9 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
|
|||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
|
||||
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug') }
|
||||
let!(:category_to_link) { create(:category, name: 'linked category', portal: portal, account_id: account.id, slug: 'linked_category_slug') }
|
||||
let!(:related_category_1) { create(:category, name: 'related category 1', portal: portal, account_id: account.id, slug: 'category_slug_1') }
|
||||
let!(:related_category_2) { create(:category, name: 'related category 2', portal: portal, account_id: account.id, slug: 'category_slug_2') }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
|
@ -15,23 +18,76 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
|
|||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
category_params = {
|
||||
category: {
|
||||
name: 'test_category',
|
||||
description: 'test_description',
|
||||
position: 1,
|
||||
locale: 'es',
|
||||
slug: 'test_category_1'
|
||||
let!(:category_params) do
|
||||
{
|
||||
category: {
|
||||
name: 'test_category',
|
||||
description: 'test_description',
|
||||
position: 1,
|
||||
locale: 'es',
|
||||
slug: 'test_category_1',
|
||||
parent_category_id: category.id,
|
||||
linked_category_id: category_to_link.id,
|
||||
related_category_ids: [related_category_1.id, related_category_2.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let!(:category_params_2) do
|
||||
{
|
||||
category: {
|
||||
name: 'test_category_2',
|
||||
description: 'test_description_2',
|
||||
position: 1,
|
||||
locale: 'es',
|
||||
slug: 'test_category_2',
|
||||
parent_category_id: category.id,
|
||||
linked_category_id: category_to_link.id,
|
||||
related_category_ids: [related_category_1.id, related_category_2.id]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates category' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['payload']['name']).to eql('test_category')
|
||||
|
||||
expect(json_response['payload']['related_categories'][0]['id']).to eql(related_category_1.id)
|
||||
expect(json_response['payload']['related_categories'][1]['id']).to eql(related_category_2.id)
|
||||
expect(json_response['payload']['parent_category']['id']).to eql(category.id)
|
||||
expect(json_response['payload']['linked_category']['id']).to eql(category_to_link.id)
|
||||
expect(category.reload.sub_category_ids).to eql([Category.last.id])
|
||||
expect(category_to_link.reload.linked_category_ids).to eql([Category.last.id])
|
||||
end
|
||||
|
||||
it 'creates multiple sub_categories under one parent_category' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params_2,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(category.reload.sub_category_ids).to eql(Category.last(2).pluck(:id))
|
||||
end
|
||||
|
||||
it 'creates multiple linked_categories with one category' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params_2,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(category_to_link.reload.linked_category_ids).to eql(Category.last(2).pluck(:id))
|
||||
end
|
||||
|
||||
it 'will throw an error on locale, category_id uniqueness' do
|
||||
|
@ -81,18 +137,74 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
|
|||
category: {
|
||||
name: 'test_category_2',
|
||||
description: 'test_description',
|
||||
position: 1
|
||||
position: 1,
|
||||
related_category_ids: [related_category_1.id],
|
||||
parent_category_id: related_category_2.id
|
||||
}
|
||||
}
|
||||
|
||||
expect(category.name).not_to eql(category_params[:category][:name])
|
||||
expect(category.related_categories).to be_empty
|
||||
expect(category.parent_category).to be_nil
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response['payload']['name']).to eql(category_params[:category][:name])
|
||||
expect(json_response['payload']['related_categories'][0]['id']).to eql(related_category_1.id)
|
||||
expect(json_response['payload']['parent_category']['id']).to eql(related_category_2.id)
|
||||
expect(related_category_2.reload.sub_category_ids).to eql([category.id])
|
||||
end
|
||||
|
||||
it 'updates related categories' do
|
||||
category_params = {
|
||||
category: {
|
||||
related_category_ids: [related_category_1.id]
|
||||
}
|
||||
}
|
||||
category.related_categories << related_category_2
|
||||
category.save!
|
||||
|
||||
expect(category.related_category_ids).to eq([related_category_2.id])
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response['payload']['name']).to eql(category.name)
|
||||
expect(json_response['payload']['related_categories'][0]['id']).to eql(related_category_1.id)
|
||||
expect(category.reload.related_category_ids).to eq([related_category_1.id])
|
||||
expect(related_category_1.reload.related_category_ids).to be_empty
|
||||
end
|
||||
|
||||
# [category_1, category_2] !== [category_2, category_1]
|
||||
it 'update reverse associations for related categories' do
|
||||
category.related_categories << related_category_2
|
||||
category.save!
|
||||
|
||||
expect(category.related_category_ids).to eq([related_category_2.id])
|
||||
|
||||
category_params = {
|
||||
category: {
|
||||
related_category_ids: [category.id]
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{related_category_2.id}",
|
||||
params: category_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
expect(category.reload.related_category_ids).to eq([related_category_2.id])
|
||||
expect(related_category_2.reload.related_category_ids).to eq([category.id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -125,7 +237,9 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
|
|||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'get all portals' do
|
||||
it 'get all categories in portal' do
|
||||
category_count = Category.all.count
|
||||
|
||||
category2 = create(:category, name: 'test_category_2', portal: portal, locale: 'es', slug: 'category_slug_2')
|
||||
expect(category2.id).not_to be nil
|
||||
|
||||
|
@ -133,7 +247,7 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
|
|||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['payload'].count).to be 2
|
||||
expect(json_response['payload'].count).to be(category_count + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
6
spec/factories/related_categories.rb
Normal file
6
spec/factories/related_categories.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :related_category do
|
||||
category
|
||||
related_category
|
||||
end
|
||||
end
|
|
@ -9,8 +9,10 @@ RSpec.describe Category, type: :model do
|
|||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:portal) }
|
||||
it { is_expected.to have_many(:folders) }
|
||||
it { is_expected.to have_many(:articles) }
|
||||
it { is_expected.to have_many(:sub_categories) }
|
||||
it { is_expected.to have_many(:linked_categories) }
|
||||
it { is_expected.to have_many(:related_categories) }
|
||||
end
|
||||
|
||||
describe 'search' do
|
||||
|
|
8
spec/models/related_category_spec.rb
Normal file
8
spec/models/related_category_spec.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RelatedCategory, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:category) }
|
||||
it { is_expected.to belong_to(:related_category) }
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue