コミットを比較
1 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| d6670bb65e |
@@ -140,11 +140,10 @@ class PostsController < ApplicationController
|
|||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
parent_post_ids = parse_parent_post_ids
|
parent_post_ids = parse_parent_post_ids
|
||||||
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
|
|
||||||
|
|
||||||
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
||||||
original_created_from:, original_created_before:)
|
original_created_from:, original_created_before:)
|
||||||
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
|
post.thumbnail.attach(thumbnail) if thumbnail.present?
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
@@ -157,6 +156,8 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
sync_parent_posts!(post, parent_post_ids)
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
|
|
||||||
|
post.resized_thumbnail!
|
||||||
|
|
||||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -166,8 +167,6 @@ class PostsController < ApplicationController
|
|||||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||||
rescue MiniMagick::Error
|
|
||||||
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
|
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
class Post < ApplicationRecord
|
class Post < ApplicationRecord
|
||||||
require 'mini_magick'
|
require 'mini_magick'
|
||||||
require 'stringio'
|
|
||||||
|
|
||||||
def self.resized_thumbnail_attachment(upload)
|
|
||||||
upload.rewind
|
|
||||||
image = MiniMagick::Image.read(upload.read)
|
|
||||||
image.resize '180x180'
|
|
||||||
image.format 'jpg'
|
|
||||||
|
|
||||||
{ io: StringIO.new(image.to_blob),
|
|
||||||
filename: 'resized_thumbnail.jpg',
|
|
||||||
content_type: 'image/jpeg' }
|
|
||||||
ensure
|
|
||||||
upload.rewind
|
|
||||||
end
|
|
||||||
|
|
||||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
@@ -101,7 +87,12 @@ class Post < ApplicationRecord
|
|||||||
def resized_thumbnail!
|
def resized_thumbnail!
|
||||||
return unless thumbnail.attached?
|
return unless thumbnail.attached?
|
||||||
|
|
||||||
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
image = MiniMagick::Image.read(thumbnail.download)
|
||||||
|
image.resize '180x180'
|
||||||
|
thumbnail.purge
|
||||||
|
thumbnail.attach(io: File.open(image.path),
|
||||||
|
filename: 'resized_thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ class TagImplication < ApplicationRecord
|
|||||||
validates :parent_tag_id, presence: true
|
validates :parent_tag_id, presence: true
|
||||||
|
|
||||||
validate :parent_tag_mustnt_be_itself
|
validate :parent_tag_mustnt_be_itself
|
||||||
validate :parent_tag_mustnt_create_cycle
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -15,27 +14,4 @@ class TagImplication < ApplicationRecord
|
|||||||
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parent_tag_mustnt_create_cycle
|
|
||||||
return if tag_id.blank? || parent_tag_id.blank?
|
|
||||||
return if errors[:parent_tag_id].present?
|
|
||||||
|
|
||||||
seen = { }
|
|
||||||
stack = [parent_tag_id]
|
|
||||||
|
|
||||||
until stack.empty?
|
|
||||||
current_id = stack.pop
|
|
||||||
next if seen[current_id]
|
|
||||||
|
|
||||||
seen[current_id] = true
|
|
||||||
|
|
||||||
if current_id == tag_id
|
|
||||||
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
|
|
||||||
errors.add :base, 'タグの親子関係が循環します.'
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe TagImplication, type: :model do
|
|
||||||
it 'rejects a parent tag that would create a cycle' do
|
|
||||||
child = create(:tag, name: 'tag_implication_cycle_child')
|
|
||||||
parent = create(:tag, name: 'tag_implication_cycle_parent')
|
|
||||||
|
|
||||||
described_class.create!(tag: child, parent_tag: parent)
|
|
||||||
|
|
||||||
implication = described_class.new(tag: parent, parent_tag: child)
|
|
||||||
|
|
||||||
expect(implication).not_to be_valid
|
|
||||||
expect(implication.errors[:parent_tag_id]).to include(
|
|
||||||
'親タグに子孫タグを指定すると循環します.'
|
|
||||||
)
|
|
||||||
expect(implication.errors[:base]).to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'terminates even when existing data already contains a cycle' do
|
|
||||||
child = create(:tag, name: 'tag_implication_existing_cycle_child')
|
|
||||||
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
|
|
||||||
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
|
|
||||||
|
|
||||||
described_class.create!(tag: parent, parent_tag: ancestor)
|
|
||||||
described_class.insert_all!(
|
|
||||||
[
|
|
||||||
{ tag_id: ancestor.id, parent_tag_id: parent.id,
|
|
||||||
created_at: Time.current, updated_at: Time.current }
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
implication = described_class.new(tag: child, parent_tag: parent)
|
|
||||||
|
|
||||||
expect(implication).to be_valid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -54,17 +54,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
first = create(:tag, name: 'expand_cycle_first')
|
first = create(:tag, name: 'expand_cycle_first')
|
||||||
second = create(:tag, name: 'expand_cycle_second')
|
second = create(:tag, name: 'expand_cycle_second')
|
||||||
TagImplication.create!(tag: first, parent_tag: second)
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
now = Time.current
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
TagImplication.insert_all!(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
tag_id: second.id,
|
|
||||||
parent_tag_id: first.id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
require 'base64'
|
|
||||||
require 'set'
|
require 'set'
|
||||||
|
|
||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
@@ -9,11 +8,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||||
before do
|
before do
|
||||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
|
||||||
io: StringIO.new('dummy'),
|
|
||||||
filename: 'resized_thumbnail.jpg',
|
|
||||||
content_type: 'image/jpeg'
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_nico_tag!(name)
|
def create_nico_tag!(name)
|
||||||
@@ -25,18 +19,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||||
end
|
end
|
||||||
|
|
||||||
def real_thumbnail_upload
|
|
||||||
gif =
|
|
||||||
Base64.decode64(
|
|
||||||
'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
|
||||||
|
|
||||||
Rack::Test::UploadedFile.new(
|
|
||||||
StringIO.new(gif),
|
|
||||||
'image/gif',
|
|
||||||
original_filename: 'thumbnail.gif'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_write_params params = { }
|
def post_write_params params = { }
|
||||||
{ parent_post_ids: '' }.merge(params)
|
{ parent_post_ids: '' }.merge(params)
|
||||||
end
|
end
|
||||||
@@ -714,66 +696,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json['tags'][0]).to have_key('name')
|
expect(json['tags'][0]).to have_key('name')
|
||||||
end
|
end
|
||||||
|
|
||||||
it '201 when posting manually with a thumbnail' do
|
|
||||||
sign_in_as(member)
|
|
||||||
allow(Post).to receive(:resized_thumbnail_attachment).and_call_original
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'thumbnail post',
|
|
||||||
url: 'https://example.com/thumbnail-post',
|
|
||||||
tags: 'spec_tag',
|
|
||||||
thumbnail: real_thumbnail_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
post_record = Post.find(json.fetch('id'))
|
|
||||||
expect(post_record.thumbnail).to be_attached
|
|
||||||
expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
|
|
||||||
expect { post_record.thumbnail.download }.not_to raise_error
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'resizes the thumbnail before the create transaction begins' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
open_transactions = []
|
|
||||||
baseline_open_transactions = Post.connection.open_transactions
|
|
||||||
allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
|
|
||||||
open_transactions << Post.connection.open_transactions
|
|
||||||
{ io: StringIO.new('dummy'),
|
|
||||||
filename: 'resized_thumbnail.jpg',
|
|
||||||
content_type: 'image/jpeg' }
|
|
||||||
end
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'transaction post',
|
|
||||||
url: 'https://example.com/transaction-post',
|
|
||||||
tags: 'spec_tag',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
expect(open_transactions).to eq([baseline_open_transactions])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 and does not create a post when thumbnail resize fails' do
|
|
||||||
sign_in_as(member)
|
|
||||||
allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'broken thumbnail post',
|
|
||||||
url: 'https://example.com/broken-thumbnail-post',
|
|
||||||
tags: 'spec_tag',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
}.not_to change(Post, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it '201 and creates post + tags when member and tags have aliases' do
|
it '201 and creates post + tags when member and tags have aliases' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
|
|||||||
@@ -56,17 +56,6 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 and does not create relation when the new link makes a cycle' do
|
|
||||||
TagImplication.create!(tag: parent, parent_tag: child)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change(TagImplication, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when Tag.find raises (invalid ids)" do
|
context "when Tag.find raises (invalid ids)" do
|
||||||
|
|||||||
@@ -797,17 +797,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
)
|
)
|
||||||
TagImplication.create!(tag: first, parent_tag: root_material)
|
TagImplication.create!(tag: first, parent_tag: root_material)
|
||||||
TagImplication.create!(tag: second, parent_tag: first)
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
now = Time.current
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
TagImplication.insert_all!(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
tag_id: first.id,
|
|
||||||
parent_tag_id: second.id,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
get '/tags/with-depth', params: { parent: root_material.id }
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
@@ -1374,6 +1364,8 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||||
|
pending '#332 で対応予定'
|
||||||
|
|
||||||
child = Tag.create!(
|
child = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_cycle_child'),
|
tag_name: TagName.create!(name: 'put_cycle_child'),
|
||||||
category: :general
|
category: :general
|
||||||
|
|||||||
+1
-3
@@ -6,7 +6,6 @@ import { BrowserRouter,
|
|||||||
Routes,
|
Routes,
|
||||||
useLocation } from 'react-router-dom'
|
useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
|
||||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
@@ -146,7 +145,6 @@ const App: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
|
||||||
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
@@ -154,7 +152,7 @@ const App: FC = () => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout="position"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
|
|
||||||
const ROW_COUNT = 8
|
|
||||||
const COLUMN_COUNT = 12
|
|
||||||
|
|
||||||
|
|
||||||
const DevModeWatermark: FC = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
|
|
||||||
<style>{`
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Nikumaru';
|
|
||||||
src: url(${nikumaru}) format('opentype');
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
|
|
||||||
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
|
|
||||||
<div
|
|
||||||
key={rowIndex}
|
|
||||||
className={
|
|
||||||
'flex min-h-32 items-center gap-12 '
|
|
||||||
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
|
|
||||||
}>
|
|
||||||
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
|
||||||
<span
|
|
||||||
key={columnIndex}
|
|
||||||
className={
|
|
||||||
'whitespace-nowrap text-3xl font-bold '
|
|
||||||
+ 'tracking-[0.3em] text-neutral-950/5 '
|
|
||||||
+ 'dark:text-white/10'
|
|
||||||
}
|
|
||||||
style={{ fontFamily: 'Nikumaru' }}>
|
|
||||||
開発モード
|
|
||||||
</span>))}
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default DevModeWatermark
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
||||||
import { buildPost,
|
import { buildPost,
|
||||||
buildTag,
|
|
||||||
buildTheatre,
|
buildTheatre,
|
||||||
buildTheatreComment,
|
buildTheatreComment,
|
||||||
buildTheatreInfo,
|
buildTheatreInfo,
|
||||||
@@ -97,9 +96,6 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
|
|||||||
{ route: '/theatres/7' },
|
{ route: '/theatres/7' },
|
||||||
)
|
)
|
||||||
|
|
||||||
const tagSection = (): HTMLElement =>
|
|
||||||
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
|
|
||||||
|
|
||||||
const mockDefaultApi = () => {
|
const mockDefaultApi = () => {
|
||||||
api.apiGet.mockImplementation ((path: string) => {
|
api.apiGet.mockImplementation ((path: string) => {
|
||||||
switch (path)
|
switch (path)
|
||||||
@@ -249,53 +245,6 @@ describe ('TheatreDetailPage', () => {
|
|||||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
|
||||||
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
|
||||||
const parentTag = buildTag ({
|
|
||||||
id: 11,
|
|
||||||
name: '親タグ',
|
|
||||||
category: 'general',
|
|
||||||
children: [childTag],
|
|
||||||
})
|
|
||||||
const duplicateParentTag = buildTag ({
|
|
||||||
id: 13,
|
|
||||||
name: '別親タグ',
|
|
||||||
category: 'general',
|
|
||||||
children: [childTag],
|
|
||||||
})
|
|
||||||
|
|
||||||
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
|
|
||||||
...currentPost,
|
|
||||||
tags: [parentTag, duplicateParentTag],
|
|
||||||
}))
|
|
||||||
|
|
||||||
renderPage ()
|
|
||||||
|
|
||||||
await screen.findByText ('Embed:上映中の投稿')
|
|
||||||
|
|
||||||
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
|
|
||||||
|
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
|
|
||||||
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
|
|
||||||
|
|
||||||
await waitFor (() => {
|
|
||||||
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
|
|
||||||
.toBeInTheDocument ()
|
|
||||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
|
|
||||||
.toHaveLength (1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('does not advance host post while video length is unknown', async () => {
|
it ('does not advance host post while video length is unknown', async () => {
|
||||||
api.apiPut.mockImplementation ((path: string) => {
|
api.apiPut.mockImplementation ((path: string) => {
|
||||||
switch (path)
|
switch (path)
|
||||||
|
|||||||
@@ -102,28 +102,6 @@ const compareTagName = (a: Tag, b: Tag): number =>
|
|||||||
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
||||||
|
|
||||||
|
|
||||||
const flattenTags = (tags: Tag[]): Tag[] => {
|
|
||||||
const flattened: Tag[] = []
|
|
||||||
const seen = new Set<number> ()
|
|
||||||
|
|
||||||
const visit = (tag: Tag) => {
|
|
||||||
if (seen.has (tag.id))
|
|
||||||
return
|
|
||||||
|
|
||||||
seen.add (tag.id)
|
|
||||||
flattened.push (tag)
|
|
||||||
|
|
||||||
for (const child of tag.children ?? [])
|
|
||||||
visit (child)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tag of tags)
|
|
||||||
visit (tag)
|
|
||||||
|
|
||||||
return flattened
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
||||||
const grouped: Partial<Record<Category, Tag[]>> = { }
|
const grouped: Partial<Record<Category, Tag[]>> = { }
|
||||||
|
|
||||||
@@ -140,39 +118,15 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const renderReadonlyTagTree = (
|
|
||||||
tag: Tag,
|
|
||||||
nestLevel: number,
|
|
||||||
path: string,
|
|
||||||
ancestors: Set<number> = new Set<number> (),
|
|
||||||
): ReactNode[] => {
|
|
||||||
const key = `${ path }-${ tag.id }`
|
|
||||||
const nextAncestors = new Set (ancestors)
|
|
||||||
nextAncestors.add (tag.id)
|
|
||||||
|
|
||||||
return [
|
|
||||||
(
|
|
||||||
<li key={key} className="text-left leading-tight">
|
|
||||||
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
|
|
||||||
</li>),
|
|
||||||
...((tag.children ?? [])
|
|
||||||
.filter (child => !(nextAncestors.has (child.id)))
|
|
||||||
.sort (compareTagName)
|
|
||||||
.flatMap (child =>
|
|
||||||
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||||
{ tags, compact, flow = 'vertical' }) => {
|
{ tags, compact, flow = 'vertical' }) => {
|
||||||
const horizontalGrouped = tagsByCategory (flattenTags (tags))
|
const grouped = tagsByCategory (tags)
|
||||||
const verticalGrouped = tagsByCategory (tags)
|
|
||||||
|
|
||||||
if (flow === 'horizontal')
|
if (flow === 'horizontal')
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
||||||
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
|
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
||||||
<li key={tag.id} className="text-left leading-tight">
|
<li key={tag.id} className="text-left leading-tight">
|
||||||
<TagLink tag={tag} withCount={false}/>
|
<TagLink tag={tag} withCount={false}/>
|
||||||
</li>))}
|
</li>))}
|
||||||
@@ -182,7 +136,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{CATEGORIES.map (cat => {
|
{CATEGORIES.map (cat => {
|
||||||
const rows = verticalGrouped[cat] ?? []
|
const rows = grouped[cat] ?? []
|
||||||
if (rows.length === 0)
|
if (rows.length === 0)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -192,8 +146,10 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
{CATEGORY_NAMES[cat]}
|
{CATEGORY_NAMES[cat]}
|
||||||
</div>
|
</div>
|
||||||
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
||||||
{rows.flatMap (tag =>
|
{rows.map (tag => (
|
||||||
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
|
<li key={tag.id} className="text-left leading-tight">
|
||||||
|
<TagLink tag={tag} withCount={false}/>
|
||||||
|
</li>))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>)
|
</div>)
|
||||||
})}
|
})}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする