diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 0c1c9e3..0958c75 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -39,18 +39,23 @@ class TagVersionsController < ApplicationController cur_aliases = split_values(row.aliases) prev_aliases = split_values(row.attributes['prev_aliases']) - cur_parent_tags = - TagRepr.many( - Tag - .includes(:tag_name, :materials, { tag_name: :wiki_page }) - .where(id: split_parent_tag_ids(row.parent_tag_ids)) - .to_a) - prev_parent_tags = - TagRepr.many( - Tag - .includes(:tag_name, :materials, { tag_name: :wiki_page }) - .where(id: split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])) - .to_a) + cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) + prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) + + all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids) + + tags_by_id = + Tag + .includes(:tag_name, :materials, { tag_name: :wiki_page }) + .where(id: all_parent_tag_ids) + .index_by(&:id) + + parent_tags = + build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) + .map do |h| + { tag: TagRepr.base(tags_by_id[h[:tag_id]]), + type: h[:type] } + end { tag_id: row.tag_id, version_no: row.version_no, @@ -58,7 +63,7 @@ class TagVersionsController < ApplicationController name: { current: row.name, prev: row.attributes['prev_name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, aliases: build_version_values(cur_aliases, prev_aliases, key: :name), - parent_tags: build_version_values(cur_parent_tags, prev_parent_tags, key: :tag), + parent_tags:, created_at: row.created_at.iso8601, created_by_user: row.created_by_user_id && { id: row.created_by_user_id, diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb new file mode 100644 index 0000000..f1d92e7 --- /dev/null +++ b/backend/spec/requests/tag_versions_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' + +RSpec.describe 'TagVersions API', type: :request do + let(:member) { create(:user, :member, name: 'version member') } + + let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } + let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } + + let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } + let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } + let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } + let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } + + let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } + let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } + let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } + + def create_tag_version!( + tag:, + version_no:, + event_type:, + name:, + category:, + aliases: [], + parent_tags: [], + created_by_user:, + created_at: + ) + TagVersion.create!( + tag: tag, + version_no: version_no, + event_type: event_type, + name: name, + category: category, + aliases: Array(aliases).join(' '), + parent_tag_ids: Array(parent_tags).map(&:id).join(' '), + created_by_user: created_by_user, + created_at: created_at + ) + end + + let!(:v1) do + create_tag_version!( + tag: tag, + version_no: 1, + event_type: 'create', + name: 'old_tag_name', + category: 'general', + aliases: ['alias_shared', 'alias_old'], + parent_tags: [parent_shared, parent_old], + created_by_user: member, + created_at: t_v1 + ) + end + + let!(:v2) do + create_tag_version!( + tag: tag, + version_no: 2, + event_type: 'update', + name: 'new_tag_name', + category: 'meme', + aliases: ['alias_shared', 'alias_new'], + parent_tags: [parent_shared, parent_new], + created_by_user: member, + created_at: t_v2 + ) + end + + let!(:other_v1) do + create_tag_version!( + tag: other_tag, + version_no: 1, + event_type: 'create', + name: 'other_tag_name', + category: 'general', + aliases: ['other_alias'], + parent_tags: [other_parent], + created_by_user: member, + created_at: t_other + ) + end + + describe 'GET /tags/versions' do + it 'returns all versions in reverse chronological order when id is omitted' do + get '/tags/versions' + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(3) + + versions = json.fetch('versions') + + expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([ + [other_tag.id, 1], + [tag.id, 2], + [tag.id, 1] + ]) + end + + it 'returns versions for the specified tag with diffs' do + get '/tags/versions', params: { id: tag.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id]) + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + + latest = versions.first + expect(latest).to include( + 'tag_id' => tag.id, + 'version_no' => 2, + 'event_type' => 'update', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + + expect(latest.fetch('name')).to eq( + 'current' => 'new_tag_name', + 'prev' => 'old_tag_name' + ) + expect(latest.fetch('category')).to eq( + 'current' => 'meme', + 'prev' => 'general' + ) + expect(latest.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'context' }, + { 'name' => 'alias_new', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'removed' } + ) + expect(latest.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'context', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_new.id + ) + ), + a_hash_including( + 'type' => 'removed', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(latest.fetch('created_at')).to eq(t_v2.iso8601) + + first = versions.second + expect(first).to include( + 'tag_id' => tag.id, + 'version_no' => 1, + 'event_type' => 'create', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + expect(first.fetch('name')).to eq( + 'current' => 'old_tag_name', + 'prev' => nil + ) + expect(first.fetch('category')).to eq( + 'current' => 'general', + 'prev' => nil + ) + expect(first.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'added' } + ) + expect(first.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(first.fetch('created_at')).to eq(t_v1.iso8601) + end + + it 'returns empty when the specified tag has no versions' do + fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) + + get '/tags/versions', params: { id: fresh_tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) + end + + it 'clamps page and limit to at least 1' do + get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.size).to eq(1) + expect(versions.first['version_no']).to eq(2) + end + end +end diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index e97e3f2..6a8e732 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -47,7 +47,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, - { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, + { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, + visible: tagFlg && tag?.category !== 'nico' }] }, { name: '素材', to: '/materials', visible: false, subMenu: [ { name: '一覧', to: '/materials' }, { name: '検索', to: '/materials/search', visible: false },