コミットを比較

..

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 5766a96071 Merge branch 'main' into feature/386 2026-07-08 08:02:59 +09:00
みてるぞ 2f2b6e2afa タグ D&D 時の表示位置修正 (#268) (#400)
Reviewed-on: #400
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-08 00:01:07 +09:00
みてるぞ d6c29a21ab Merge branch 'main' into feature/386 2026-07-07 23:59:02 +09:00
みてるぞ dbb8e431f2 Merge branch 'main' into feature/386 2026-07-04 18:11:10 +09:00
みてるぞ 83cd476325 #386 2026-06-30 12:47:02 +09:00
5個のファイルの変更242行の追加37行の削除
+10 -8
ファイルの表示
@@ -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
+89
ファイルの表示
@@ -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
+75
ファイルの表示
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const dndKit = vi.hoisted (() => ({
useDraggable: vi.fn (),
useDroppable: vi.fn (),
}))
vi.mock ('@dnd-kit/core', () => dndKit)
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
const renderRow = (activeDndId?: string) => {
renderWithProviders (
<DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag}
nestLevel={2}
pathKey="cat-general-7"
suppressClickRef={{ current: false }}/>,
)
}
const tagBody = (): HTMLElement => {
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
if (link == null)
throw new Error ('tag link not found')
const body = link.closest ('div')
if (body == null)
throw new Error ('tag body not found')
return body
}
describe ('DraggableDroppableTagRow', () => {
beforeEach (() => {
vi.clearAllMocks ()
dndKit.useDraggable.mockReturnValue ({
attributes: { 'aria-describedby': 'drag-source' },
listeners: { onPointerDown: vi.fn () },
setNodeRef: vi.fn (),
transform: null })
dndKit.useDroppable.mockReturnValue ({
isOver: false,
setNodeRef: vi.fn () })
})
it ('passes dndId through draggable data for active-row tracking', () => {
renderRow ()
expect (dndKit.useDraggable).toHaveBeenCalledWith (
expect.objectContaining ({
id: 'tag-node:cat-general-7',
data: expect.objectContaining ({
dndId: 'tag-node:cat-general-7',
nestLevel: 2,
tagId: 7 }) }))
})
it ('hides only the active drag source from the explicit active dnd id', () => {
renderRow ('tag-node:cat-general-7')
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
})
it ('keeps inactive tag rows visible while another tag is dragged', () => {
renderRow ('tag-node:other')
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
})
})
+44 -22
ファイルの表示
@@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
type Props = { type Props = {
activeDndId?: string
tag: Tag tag: Tag
nestLevel: number nestLevel: number
pathKey: string pathKey: string
@@ -21,7 +22,15 @@ type Props = {
sp?: boolean } sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { const DraggableDroppableTagRow: FC<Props> = ({
activeDndId,
tag,
nestLevel,
pathKey,
parentTagId,
suppressClickRef,
sp,
}) => {
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
@@ -53,19 +62,23 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
const { attributes, const { attributes,
listeners, listeners,
setNodeRef: setDragRef, setNodeRef: setDragRef,
transform, transform } = useDraggable ({ id: dndId,
isDragging: dragging } = useDraggable ({ id: dndId, data: { kind: 'tag',
data: { kind: 'tag', dndId,
tagId: tag.id, tagId: tag.id,
parentTagId, parentTagId,
nestLevel } }) nestLevel } })
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({ const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
id: dndId, id: dndId,
data: { kind: 'tag', tagId: tag.id } }) data: { kind: 'tag', tagId: tag.id } })
const activeDragging = activeDndId === dndId
const style: CSSProperties = { transform: CSS.Translate.toString (transform), const style: CSSProperties = { transform: CSS.Translate.toString (transform),
visibility: dragging ? 'hidden' : 'visible' } visibility: activeDragging ? 'hidden' : 'visible' }
const innerClassName = cn (
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
sp && 'touch-pan-y')
return ( return (
<div <div
@@ -101,20 +114,29 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
sp && 'touch-pan-y', sp && 'touch-pan-y',
over && 'ring-2 ring-offset-2')} over && 'ring-2 ring-offset-2')}
> >
<motion.div {activeDragging
ref={setDragRef} ? (
style={style} <div
className={cn ( ref={setDragRef}
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden', style={style}
sp && 'touch-pan-y')} className={innerClassName}
{...attributes} {...attributes}
{...listeners} {...listeners}>
transition={{ layout: layoutTransition }} <TagLink tag={tag} nestLevel={nestLevel}/>
layoutId={animationMode === 'off' </div>)
? undefined : (
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}> <motion.div
<TagLink tag={tag} nestLevel={nestLevel}/> ref={setDragRef}
</motion.div> style={style}
className={innerClassName}
{...attributes}
{...listeners}
transition={{ layout: layoutTransition }}
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>)}
</div>) </div>)
} }
+24 -7
ファイルの表示
@@ -7,7 +7,7 @@ import { DndContext,
useDroppable, useDroppable,
useSensor, useSensor,
useSensors } from '@dnd-kit/core' useSensors } from '@dnd-kit/core'
import { restrictToWindowEdges } from '@dnd-kit/modifiers' import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -43,6 +43,7 @@ const alwaysMeasureDroppables = {
const renderTagTree = ( const renderTagTree = (
activeDndId: string | undefined,
tag: TagWithSections, tag: TagWithSections,
nestLevel: number, nestLevel: number,
path: string, path: string,
@@ -54,6 +55,7 @@ const renderTagTree = (
const self = ( const self = (
<li key={key} className="mb-1"> <li key={key} className="mb-1">
<DraggableDroppableTagRow <DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag} tag={tag}
nestLevel={nestLevel} nestLevel={nestLevel}
pathKey={key} pathKey={key}
@@ -67,7 +69,14 @@ const renderTagTree = (
...((tag.children ...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1) ?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child => .flatMap (child =>
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp))) renderTagTree (
activeDndId,
child,
nestLevel + 1,
key,
suppressClickRef,
tag.id,
sp)))
?? [])] ?? [])]
} }
@@ -211,6 +220,7 @@ type Props = {
sp?: boolean } sp?: boolean }
type ActiveTagDrag = { type ActiveTagDrag = {
dndId: string
tagId: number tagId: number
nestLevel: number } nestLevel: number }
@@ -251,6 +261,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
() => buildFlatTagByCategory (tags), () => buildFlatTagByCategory (tags),
[tags], [tags],
) )
const activeDndId = activeTagDrag?.dndId
const suppressClickRef = useRef (false) const suppressClickRef = useRef (false)
@@ -359,17 +370,18 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
<TagSearch/> <TagSearch/>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={sp ? tagCollisionDetection : undefined} collisionDetection={tagCollisionDetection}
measuring={sp ? alwaysMeasureDroppables : undefined} measuring={alwaysMeasureDroppables}
onDragStart={e => { onDragStart={e => {
if (e.active.data.current?.kind === 'tag') if (e.active.data.current?.kind === 'tag')
{ {
const tagId = e.active.data.current?.tagId const tagId = e.active.data.current?.tagId
const nestLevel = e.active.data.current?.nestLevel const nestLevel = e.active.data.current?.nestLevel
const dndId = e.active.data.current?.dndId
setActiveTagDrag ( setActiveTagDrag (
tagId == null || nestLevel == null tagId == null || nestLevel == null || dndId == null
? null ? null
: { tagId, nestLevel }) : { dndId, tagId, nestLevel })
} }
setDragging (true) setDragging (true)
suppressClickRef.current = true suppressClickRef.current = true
@@ -426,6 +438,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
{(tagRelationDisplay === 'grouped' {(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag => ? (tags[cat] ?? []).flatMap (tag =>
renderTagTree ( renderTagTree (
activeDndId,
tag, tag,
0, 0,
`cat-${ cat }`, `cat-${ cat }`,
@@ -437,6 +450,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
: (flatTagsByCategory[cat] ?? []).map (tag => ( : (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1"> <li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow <DraggableDroppableTagRow
activeDndId={activeDndId}
tag={tag} tag={tag}
nestLevel={0} nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`} pathKey={`flat-${ cat }-${ tag.id }`}
@@ -489,7 +503,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</ul> </ul>
</motion.div>)} </motion.div>)}
<DragOverlay adjustScale={false}> <DragOverlay
adjustScale={false}
modifiers={[snapCenterToCursor]}
dropAnimation={animationMode === 'off' ? null : undefined}>
<div className="pointer-events-none"> <div className="pointer-events-none">
{activeTagDrag != null && (() => { {activeTagDrag != null && (() => {
const tag = findTag (tags, activeTagDrag.tagId) const tag = findTag (tags, activeTagDrag.tagId)