コミットを比較
1 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 83cd476325 |
@@ -89,15 +89,13 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag = resolve_material_tag!(tag_name_raw)
|
||||||
tag = tag_name.tag
|
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
|
||||||
|
|
||||||
material = Material.new(tag:, url:,
|
material = Material.new(tag:, url:,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
updated_by_user: current_user)
|
updated_by_user: current_user)
|
||||||
material.file.attach(uploaded_blob) if uploaded_blob
|
material.file.attach(uploaded_blob) if uploaded_blob
|
||||||
material.save!
|
material.save!
|
||||||
|
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -139,10 +137,7 @@ class MaterialsController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag = resolve_material_tag!(tag_name_raw)
|
||||||
tag = tag_name.tag
|
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
|
||||||
|
|
||||||
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||||
if uploaded_blob
|
if uploaded_blob
|
||||||
material.file.attach(uploaded_blob)
|
material.file.attach(uploaded_blob)
|
||||||
@@ -150,6 +145,7 @@ class MaterialsController < ApplicationController
|
|||||||
material.file.detach
|
material.file.detach
|
||||||
end
|
end
|
||||||
material.save!
|
material.save!
|
||||||
|
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :update,
|
MaterialVersionRecorder.record!(material:, event_type: :update,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -240,6 +236,12 @@ class MaterialsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_material_tag! tag_name_raw
|
||||||
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag || Tag.create!(tag_name:, category: :material)
|
||||||
|
end
|
||||||
|
|
||||||
def material_index_needs_tag_name? filters
|
def material_index_needs_tag_name? filters
|
||||||
filters[:q].present? || filters[:sort] == 'tag_name'
|
filters[:q].present? || filters[:sort] == 'tag_name'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -287,6 +287,25 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
|
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates a create tag_version for a newly created material tag' do
|
||||||
|
expect do
|
||||||
|
post '/materials', params: {
|
||||||
|
tag: 'material_create_versioned_tag',
|
||||||
|
file: dummy_upload(filename: 'created.png')
|
||||||
|
}
|
||||||
|
end.to change(TagVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
|
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
|
||||||
|
version = tag.tag_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.name).to eq('material_create_versioned_tag')
|
||||||
|
expect(version.category).to eq('material')
|
||||||
|
expect(version.created_by_user).to eq(member_user)
|
||||||
|
end
|
||||||
|
|
||||||
it 'snapshots attached file metadata and sha256' do
|
it 'snapshots attached file metadata and sha256' do
|
||||||
post '/materials', params: {
|
post '/materials', params: {
|
||||||
tag: 'material_create_file_version',
|
tag: 'material_create_file_version',
|
||||||
@@ -466,6 +485,73 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates a create tag_version when update creates a new tag' do
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_versioned_tag',
|
||||||
|
file: dummy_upload(filename: 'updated.png')
|
||||||
|
}
|
||||||
|
end.to change(Tag, :count).by(1)
|
||||||
|
.and change(TagName, :count).by(1)
|
||||||
|
.and change(TagVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
|
||||||
|
version = tag.tag_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.name).to eq('material_update_versioned_tag')
|
||||||
|
expect(version.category).to eq('material')
|
||||||
|
expect(version.created_by_user).to eq(member_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'backfills a create tag_version for an existing material tag without history' do
|
||||||
|
existing_tag =
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
|
||||||
|
category: :material)
|
||||||
|
|
||||||
|
expect(existing_tag.tag_versions).to be_empty
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_existing_no_history',
|
||||||
|
file: dummy_upload(filename: 'updated.png')
|
||||||
|
}
|
||||||
|
end.to change(TagVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = existing_tag.reload.tag_versions.order(:version_no).last
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.name).to eq('material_update_existing_no_history')
|
||||||
|
expect(version.category).to eq('material')
|
||||||
|
expect(version.created_by_user).to eq(member_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'backfills a create tag_version for an existing character tag without history' do
|
||||||
|
existing_tag =
|
||||||
|
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
|
||||||
|
category: :character)
|
||||||
|
|
||||||
|
expect(existing_tag.tag_versions).to be_empty
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put "/materials/#{ material.id }", params: {
|
||||||
|
tag: 'material_update_character_no_history',
|
||||||
|
file: dummy_upload(filename: 'updated.png')
|
||||||
|
}
|
||||||
|
end.to change(TagVersion, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
version = existing_tag.reload.tag_versions.order(:version_no).last
|
||||||
|
expect(version.event_type).to eq('create')
|
||||||
|
expect(version.name).to eq('material_update_character_no_history')
|
||||||
|
expect(version.category).to eq('character')
|
||||||
|
expect(version.created_by_user).to eq(member_user)
|
||||||
|
end
|
||||||
|
|
||||||
it 'detaches the existing file without purging blob when url replaces file' do
|
it 'detaches the existing file without purging blob when url replaces file' do
|
||||||
old_blob_id = material.file.blob.id
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
@@ -494,6 +580,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
it 'does not increase version for the same snapshot update' do
|
it 'does not increase version for the same snapshot update' do
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: member_user)
|
created_by_user: member_user)
|
||||||
|
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
@@ -501,6 +588,8 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
}
|
}
|
||||||
end.not_to change(MaterialVersion, :count)
|
end.not_to change(MaterialVersion, :count)
|
||||||
|
|
||||||
|
expect(tag.reload.tag_versions.count).to eq(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(material.reload.version_no).to eq(1)
|
expect(material.reload.version_no).to eq(1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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>)
|
||||||
})}
|
})}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする