Browse Source

#171

feature/171
みてるぞ 2 weeks ago
parent
commit
de86879e79
7 changed files with 339 additions and 67 deletions
  1. +79
    -42
      backend/app/controllers/posts_controller.rb
  2. +1
    -0
      backend/app/models/tag.rb
  3. +15
    -10
      frontend/src/App.tsx
  4. +48
    -8
      frontend/src/components/PostEditForm.tsx
  5. +184
    -0
      frontend/src/components/dialogues/DialogueProvider.tsx
  6. +9
    -4
      frontend/src/lib/posts.ts
  7. +3
    -3
      frontend/src/pages/posts/PostHistoryPage.tsx

+ 79
- 42
backend/app/controllers/posts_controller.rb View File

@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:materials, { tag_name: :wiki_page }])
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail

q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end

def random
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
return head :not_found unless post
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
end

def show
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post

render json: PostRepr.base(post, current_user)
@@ -173,8 +173,12 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

base_version_no = parse_base_version_no
force = truthy_param?(params[:force])
merge = truthy_param?(params[:merge])
return head :bad_request if force && merge

base_version_no = nil
base_version_no = parse_base_version_no unless force

title = params[:title].presence
tag_names = params[:tags].to_s.split
@@ -186,12 +190,17 @@ class PostsController < ApplicationController
conflict_json = nil

ApplicationRecord.transaction do
post = Post.find(params[:id].to_i)
post = Post.lock.find(params[:id].to_i)

base_version = post.post_versions.find_by!(version_no: base_version_no)
base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)

base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(post,
title:,
original_created_from:,
@@ -199,29 +208,28 @@ class PostsController < ApplicationController
tag_names:,
parent_post_ids:)

if !(force) && post.version_no != base_version_no
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:)
raise ActiveRecord::Rollback
end

PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title:, original_created_from:, original_created_before:)

normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)

tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
snapshot_to_apply =
if post.version_no == base_version_no || force
incoming_snapshot
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }

sync_parent_posts!(post, parent_post_ids)
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
apply_post_snapshot!(post, snapshot_to_apply)
end

return render json: conflict_json, status: :conflict if conflict_json
@@ -253,7 +261,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:materials, { tag_name: :wiki_page }])
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])

events = []
pts.each do |pt|
@@ -446,11 +454,11 @@ class PostsController < ApplicationController
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: version.tags.to_s.split.sort,
tag_names: version.tags.to_s.split.filter { !(_1.start_with?('nico:')) }.sort,
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end

def post_snapshot_form_record post
def post_snapshot_from_record post
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
@@ -460,7 +468,7 @@ class PostsController < ApplicationController

def post_incoming_snapshot post, title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
{ title:
{ title:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(post, tag_names),
@@ -488,17 +496,16 @@ class PostsController < ApplicationController

def incoming_tag_names_for_snapshot post, raw_tag_names
manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names)
nico_names = post.tags.nico.joins(:tag_name).pluck('tag_names.name')

existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: manual_names + nico_names })
.where(tag_names: { name: manual_names })
.to_a

expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name)

(manual_names + nico_names + expanded_names).uniq.sort
(manual_names + expanded_names).uniq.sort
end

def normalised_manual_tag_names_for_snapshot raw_tag_names
@@ -528,10 +535,7 @@ class PostsController < ApplicationController
end

def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }

current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
@@ -548,9 +552,9 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, '元コンテンツ作成日時(開始)',
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, '元コンテンツ作成日時(終了)',
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot),
@@ -606,4 +610,37 @@ class PostsController < ApplicationController
added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end

def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])

tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)

tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)

sync_parent_posts!(post, snapshot[:parent_post_ids])

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end

def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before, :tag_names, :parent_post_ids].map {
[_1, merge_scaler_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h
end

def merge_scaler_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine

raise ArgumentError, '競合してゐる項目はマージできません.'
end
end

+ 1
- 0
backend/app/models/tag.rb View File

@@ -136,6 +136,7 @@ class Tag < ApplicationRecord
tn = tn.canonical if tn.canonical_id?

Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
t.version_no = TagVersion.where(tag_id: t.id).order(version_no: :desc).first || 1
t.category = category
end
rescue ActiveRecord::RecordNotUnique


+ 15
- 10
frontend/src/App.tsx View File

@@ -8,6 +8,7 @@ import { BrowserRouter,

import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -138,17 +139,21 @@ export default (() => {
return (
<>
<RouteBlockerOverlay/>

<BrowserRouter>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<Toaster/>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>

<Toaster/>
</DialogueProvider>
</BrowserRouter>
</>)
}) satisfies FC

+ 48
- 8
frontend/src/components/PostEditForm.tsx View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { updatePost } from '@/lib/posts'
@@ -41,29 +42,68 @@ export default (({ post, onSave }: Props) => {
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)

const handleSubmit = async () => {
const dialogue = useDialogue ()

const update = async (...args: Parameters<typeof updatePost>) => {
try
{
const data =
await updatePost ({ id: post.id, versionNo: post.versionNo + 1,
title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore })
const data = await updatePost (...args)
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
tags: data.tags,
parentPosts: data.parentPosts,
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch
catch (e)
{
toast ({ description: '更新はできなかったよ……' })
if (e.response.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
}

const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p>
</div>),
choices: [{ value: 'merge', label: '差分をマージ' },
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })

if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}

if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
}
}

const handleSubmit = async e => {
e.preventDefault ()

await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}

useEffect (() => {
setTags(tagsToStr (post.tags))
}, [post])


+ 184
- 0
frontend/src/components/dialogues/DialogueProvider.tsx View File

@@ -0,0 +1,184 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'

import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle } from '@/components/ui/dialog'

import type { FC, ReactNode } from 'react'

type DialogueVariant = 'default' | 'danger'

type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }

type AlertOptions = { title: string
description?: ReactNode
okText?: string }

type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }

type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }

type DialogueRequest =
| { id: number
kind: 'confirm'
options: ConfirmOptions
resolve: (value: boolean) => void }
| { id: number
kind: 'alert'
options: AlertOptions
resolve: () => void }
| { id: number
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }

type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }

const DialogueContext = createContext<DialogueAPI | null> (null)

let nextDialogueId = 1

type Props = { children: ReactNode }


export default (({ children }: Props) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])

const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId

setQueue (q => [...q, { ...request, id } as DialogueRequest])
}, [])

const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q

if (!(active))
return rest

switch (active.kind)
{
case 'confirm':
active.resolve (Boolean (result))
break

case 'alert':
active.resolve ()
break

case 'choice':
active.resolve ((result ?? null) as string | null)
break
}

return rest
})
}, [])

const api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
}),
alert: options => new Promise<void> (resolve => {
push ({ kind: 'alert', options, resolve })
}),
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])

const active = queue[0]

return (
<DialogueContext.Provider value={api}>
{children}

<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent>
<DialogTitle>{active.options.title}</DialogTitle>

{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}

<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>

<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}

{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}

{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>

{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}) satisfies FC<Props>


export const useDialogue = () => {
const dialogue = useContext (DialogueContext)

if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')

return dialogue
}

+ 9
- 4
frontend/src/lib/posts.ts View File

@@ -44,21 +44,26 @@ export const fetchPostChanges = async (

export const updatePost = async (
post: { id: number
versionNo: number
title: string | null
tags: string
parentPostIds: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null },
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ version_no: post.versionNo,
title: post.title,
{ title: post.title,
tags: post.tags,
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore })
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })


export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {


+ 3
- 3
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -73,7 +73,6 @@ export default (() => {
try
{
const id = change.postId
const versionNo = change.latestVersionNo + 1
const title = change.title.current
const tags =
change.tags
@@ -88,8 +87,9 @@ export default (() => {
.join (' ')
const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, versionNo, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore })
await updatePost ({ id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })

qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })


Loading…
Cancel
Save