Compare commits

...

3 Commits

Author SHA1 Message Date
みてるぞ d8b028a4dd #171 2026-05-10 06:52:12 +09:00
みてるぞ 49c82c626a #171 2026-05-10 06:11:57 +09:00
みてるぞ 35e5af2f9a #171 2026-05-10 05:32:08 +09:00
16 changed files with 542 additions and 163 deletions
+7 -5
View File
@@ -177,8 +177,8 @@ class PostsController < ApplicationController
merge = bool?(:merge) merge = bool?(:merge)
return head :bad_request if force && merge return head :bad_request if force && merge
base_version_no = nil base_version_no = parse_base_version_no
base_version_no = parse_base_version_no unless force return head :bad_request if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
@@ -442,9 +442,11 @@ class PostsController < ApplicationController
def parse_base_version_no def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false) version_no = Integer(params[:base_version_no], exception: false)
raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive? if version_no&.positive?
version_no
version_no else
nil
end
end end
def post_snapshot_from_version version def post_snapshot_from_version version
+3 -3
View File
@@ -16,7 +16,7 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id) @record = record_class.unscoped.lock.find(@record.id)
latest = latest_version latest = latest_version
validate_version_sequence! latest validate_version_sequence!(latest)
attrs = snapshot_attributes attrs = snapshot_attributes
@@ -27,7 +27,7 @@ class VersionRecorder
version = version_class.create!( version = version_class.create!(
base_attributes(latest).merge(record_key => @record).merge(attrs)) base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no! version.version_no update_record_version_no!(version.version_no)
version version
end end
@@ -47,7 +47,7 @@ class VersionRecorder
end end
def update_record_version_no! version_no def update_record_version_no! version_no
@record.update_columns version_no: version_no @record.update_columns(version_no:)
@record.version_no = version_no @record.version_no = version_no
end end
+286 -65
View File
@@ -10,6 +10,10 @@ RSpec.describe 'Posts API', type: :request 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)
end end
def create_nico_tag!(name)
Tag.find_or_create_by_tag_name!(name, category: :nico)
end
def dummy_upload def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる) # 中身は何でもいい(加工処理はスタブしてる)
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')
@@ -23,21 +27,34 @@ RSpec.describe 'Posts API', type: :request do
Post.create!(title:, url:) Post.create!(title:, url:)
end end
def create_post_version_for! post def create_post_version_for!(post)
PostVersion.create!( version =
post:, PostVersion.create!(
version_no: 1, post:,
event_type: 'create', version_no: 1,
title: post.title, event_type: 'create',
url: post.url, title: post.title,
thumbnail_base: post.thumbnail_base, url: post.url,
tags: post.snapshot_tag_names.join(' '), thumbnail_base: post.thumbnail_base,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), tags: post.snapshot_tag_names.join(' '),
original_created_from: post.original_created_from, parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_before: post.original_created_before, original_created_from: post.original_created_from,
created_at: post.created_at, original_created_before: post.original_created_before,
created_by_user: post.uploaded_user created_at: post.created_at,
) created_by_user: post.uploaded_user)
post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no)
post.version_no = version.version_no if post.respond_to?(:version_no=)
version
end
def post_update_params(post, params = { })
base_version =
post.post_versions.order(version_no: :desc).first ||
create_post_version_for!(post.reload)
post_write_params({ base_version_no: base_version.version_no }.merge(params))
end end
let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
@@ -806,24 +823,26 @@ RSpec.describe 'Posts API', type: :request do
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') put "/posts/#{post_record.id}", params: post_update_params(
post_record, title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it '403 when not member' do it '403 when not member' do
sign_in_as(create(:user, role: 'guest')) sign_in_as(create(:user, role: 'guest'))
put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') put "/posts/#{post_record.id}", params: post_update_params(
post_record, title: 'updated', tags: 'spec_tag')
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
it '200 and updates title + resync tags when member' do it '200 and updates title + resync tags when member' do
sign_in_as(member) sign_in_as(member)
# 追加で別タグも作って、更新時に入れ替わることを見る
tn2 = TagName.create!(name: 'spec_tag_2') tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general) Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag_2') tags: 'spec_tag_2')
@@ -831,7 +850,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json).to have_key('tags') expect(json).to have_key('tags')
expect(json['tags']).to be_an(Array) expect(json['tags']).to be_an(Array)
# show と同様、update 後レスポンスもツリー形式
names = json['tags'].map { |n| n['name'] } names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2') expect(names).to include('spec_tag_2')
end end
@@ -846,10 +864,10 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do it 'return 400' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
title: 'updated title', post_record,
tags: 'nico:nico_tag' title: 'updated title',
) tags: 'nico:nico_tag')
expect(response).to have_http_status(:bad_request), response.body expect(response).to have_http_status(:bad_request), response.body
end end
@@ -887,11 +905,11 @@ RSpec.describe 'Posts API', type: :request do
it 'replaces parent posts' do it 'replaces parent posts' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
title: 'updated title', post_record,
tags: 'spec_tag', title: 'updated title',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" tags: 'spec_tag',
) parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -908,7 +926,8 @@ RSpec.describe 'Posts API', type: :request do
it 'clears parent posts when parent_post_ids is blank' do it 'clears parent posts when parent_post_ids is blank' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
parent_post_ids: '' parent_post_ids: ''
@@ -922,7 +941,8 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member) sign_in_as(member)
create_post_version_for!(post_record.reload) create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
@@ -943,7 +963,10 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do it 'returns 422' do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: { put "/posts/#{post_record.id}", params: {
base_version_no: base_version.version_no,
title: 'updated title', title: 'updated title',
tags: 'spec_tag' } tags: 'spec_tag' }
@@ -966,7 +989,8 @@ RSpec.describe 'Posts API', type: :request do
parent_post: parent_post:
) )
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
parent_post_ids: 'abc' parent_post_ids: 'abc'
@@ -991,7 +1015,8 @@ RSpec.describe 'Posts API', type: :request do
parent_post: parent_post:
) )
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
parent_post_ids: '999999999' parent_post_ids: '999999999'
@@ -1006,7 +1031,8 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422 and does not create self implication' do it 'returns 422 and does not create self implication' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
parent_post_ids: post_record.id.to_s parent_post_ids: post_record.id.to_s
@@ -1020,6 +1046,221 @@ RSpec.describe 'Posts API', type: :request do
)).to be(false) )).to be(false)
end end
end end
context 'with optimistic locking' do
let!(:no_deerjikist_tag) { Tag.no_deerjikist }
before do
PostTag.create!(post: post_record, tag: no_deerjikist_tag)
end
it '400 when base_version_no is missing without force' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag')
expect(response).to have_http_status(:bad_request)
end
it '400 when force and merge are both true' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'updated title',
tags: 'spec_tag',
force: '1',
merge: '1')
expect(response).to have_http_status(:bad_request)
end
it '409 when scalar fields are changed both by current and incoming updates' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
post_record.update!(title: 'updated by other user')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated by me',
tags: "spec_tag #{Tag.no_deerjikist.name}")
expect(response).to have_http_status(:conflict)
expect(json.fetch('error')).to eq('conflict')
expect(json.fetch('base_version_no')).to eq(base_version.version_no)
expect(json.fetch('current_version_no')).to eq(2)
expect(json.fetch('mergeable')).to be(false)
conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') }
expect(conflict_fields).to include('title')
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")
expect(response).to have_http_status(:conflict)
expect(json.fetch('mergeable')).to be(true)
tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' }
expect(tag_change).to be_present
expect(tag_change.fetch('conflict')).to be(false)
expect(tag_change.fetch('added_by_current')).to include('current_added_tag')
expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag')
end
it 'merges non-conflicting stale tag changes when merge is true' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
merge: '1')
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include('current_merge_tag')
expect(names).to include('incoming_merge_tag')
end
it 'does not conflict when only nico tags changed after the base version' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(post_record.reload.version_no).to eq(2)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
end
it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include(nico_tag.name)
end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:relation_source')
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title,
tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok)
names = post_record.reload.tags.map(&:name)
expect(names).to include(nico_tag.name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name)
end
it 'force-updates stale posts without base_version_no' do
sign_in_as(member)
create_post_version_for!(post_record.reload)
post_record.update!(title: 'updated by other user')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
put "/posts/#{post_record.id}", params: post_write_params(
title: 'forced title',
tags: "spec_tag #{Tag.no_deerjikist.name}",
force: '1')
expect(response).to have_http_status(:ok)
expect(post_record.reload.title).to eq('forced title')
end
end
end end
describe 'GET /posts/random' do describe 'GET /posts/random' do
@@ -1434,13 +1675,14 @@ RSpec.describe 'Posts API', type: :request do
it 'creates next version on PUT /posts/:id when snapshot changes' do it 'creates next version on PUT /posts/:id when snapshot changes' do
sign_in_as(member) sign_in_as(member)
create_post_version_for!(post_record) base_version = create_post_version_for!(post_record)
tag_name2 = TagName.create!(name: 'spec_tag_2') tag_name2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tag_name2, category: :general) Tag.create!(tag_name: tag_name2, category: :general)
expect do expect do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title', title: 'updated title',
tags: 'spec_tag_2') tags: 'spec_tag_2')
end.to change(PostVersion, :count).by(1) end.to change(PostVersion, :count).by(1)
@@ -1459,13 +1701,15 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member) sign_in_as(member)
PostTag.create!(post: post_record, tag: Tag.no_deerjikist) PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
expect { expect {
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: post_record.title, title: post_record.title,
tags: 'spec_tag') tags: 'spec_tag')
}.not_to change(PostVersion, :count) }.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
version = post_record.reload.post_versions.order(:version_no).last version = post_record.reload.post_versions.order(:version_no).last
@@ -1490,10 +1734,11 @@ RSpec.describe 'Posts API', type: :request do
it 'does not create a version when PUT /posts/:id is invalid' do it 'does not create a version when PUT /posts/:id is invalid' do
sign_in_as(member) sign_in_as(member)
create_post_version_for!(post_record) base_version = create_post_version_for!(post_record)
expect do expect do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title', title: 'updated title',
tags: 'spec_tag', tags: 'spec_tag',
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
@@ -1507,46 +1752,22 @@ RSpec.describe 'Posts API', type: :request do
describe 'tag versioning from post write actions' do describe 'tag versioning from post write actions' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
it 'creates tag snapshot for normalised tags on POST /posts' do
sign_in_as(member)
expect {
post '/posts', params: post_write_params(
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload)
}.to change { tag.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:created)
version = tag.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag')
expect(version.category).to eq('general')
expect(version.created_by_user_id).to eq(member.id)
end
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
tag_name2 = TagName.create!(name: 'spec_tag_2') tag_name2 = TagName.create!(name: 'spec_tag_2')
tag2 = Tag.create!(tag_name: tag_name2, category: :general) tag2 = Tag.create!(tag_name: tag_name2, category: :general)
expect { expect {
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title', title: 'updated title',
tags: 'spec_tag_2') tags: 'spec_tag_2')
}.to change { tag2.reload.tag_versions.count }.by(1) }.to change { tag2.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok), response.body
version = tag2.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag_2')
expect(version.created_by_user_id).to eq(member.id)
end end
end end
end end
+16 -11
View File
@@ -26,17 +26,22 @@ RSpec.describe 'TagVersions API', type: :request do
created_by_user:, created_by_user:,
created_at: created_at:
) )
TagVersion.create!( version =
tag: tag, TagVersion.create!(
version_no: version_no, tag: tag,
event_type: event_type, version_no: version_no,
name: name, event_type: event_type,
category: category, name: name,
aliases: Array(aliases).join(' '), category: category,
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), aliases: Array(aliases).join(' '),
created_by_user: created_by_user, parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_at: created_at created_by_user: created_by_user,
) created_at: created_at)
tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no)
tag.version_no = version_no if tag.respond_to?(:version_no=)
version
end end
let!(:v1) do let!(:v1) do
@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe VersionRecorder do
let(:member) { create(:user, :member) }
let(:post_record) do
Post.create!(
title: 'version recorder post',
url: 'https://example.com/version-recorder-post')
end
it 'updates record version_no when creating the first version' do
version =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect(version.version_no).to eq(1)
expect(post_record.reload.version_no).to eq(1)
end
it 'updates record version_no when creating the next version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated version recorder post')
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version.version_no).to eq(2)
expect(post_record.reload.version_no).to eq(2)
end
it 'does not create a new version or advance version_no when snapshot is unchanged' do
first =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect {
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version).to eq(first)
}.not_to change(PostVersion, :count)
expect(post_record.reload.version_no).to eq(1)
end
it 'raises when record version_no is older than the latest version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated once')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
post_record.update_columns(version_no: 1)
post_record.update!(title: 'updated with stale version_no')
expect {
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
}.to raise_error(RuntimeError, /version_no/)
end
end
+13 -9
View File
@@ -103,10 +103,16 @@ export default (({ post, onSave }: Props) => {
e.preventDefault () e.preventDefault ()
setDisabled (true) setDisabled (true)
await update ({ id: post.id, title, tags, parentPostIds, try
originalCreatedFrom, originalCreatedBefore }, {
{ baseVersionNo: post.versionNo }) await update ({ id: post.id, title, tags, parentPostIds,
setDisabled (false) originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
finally
{
setDisabled (false)
}
} }
useEffect (() => { useEffect (() => {
@@ -114,7 +120,7 @@ export default (({ post, onSave }: Props) => {
}, [post]) }, [post])
return ( return (
<div className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
{/* タイトル */} {/* タイトル */}
<div> <div>
<Label></Label> <Label></Label>
@@ -154,10 +160,8 @@ export default (({ post, onSave }: Props) => {
{/* 送信 */} {/* 送信 */}
<Button <Button
type="submit" type="submit"
disabled={disabled} disabled={disabled}>
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</Button> </Button>
</div>) </form>)
}) satisfies FC<Props> }) satisfies FC<Props>
+13 -5
View File
@@ -3,6 +3,7 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -16,6 +17,8 @@ type Props = {
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
const dialogue = useDialogue ()
const url = new URL (post.url) const url = new URL (post.url)
switch (url.hostname.split ('.').slice (-2).join ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
@@ -82,12 +85,17 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
height={360}/>) height={360}/>)
: ( : (
<div> <div>
<a href="#" onClick={e => { <a href="#" onClick={async e => {
e.preventDefault () e.preventDefault ()
setFramed (confirm ('未確認の外部ページを表示します。\n'
+ '悪意のあるスクリプトが実行される可能性があります。\n' setFramed (await dialogue.confirm ({
+ '表示しますか?')) title: '未確認の外部ページを表示します',
return description: (
<div>
<p></p>
<p>?</p>
</div>),
confirmText: '表示' }))
}}> }}>
</a> </a>
+5 -6
View File
@@ -31,10 +31,9 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }` `${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
& { tags: string tags: string
setTags: (tags: string) => void } setTags: (tags: string) => void }
& ComponentPropsWithoutRef<'textarea'>
export default (({ tags, setTags, ...rest }: Props) => { export default (({ tags, setTags, ...rest }: Props) => {
@@ -77,6 +76,7 @@ export default (({ tags, setTags, ...rest }: Props) => {
<div className="relative w-full"> <div className="relative w-full">
<Label></Label> <Label></Label>
<TextArea <TextArea
{...rest}
ref={ref} ref={ref}
value={tags} value={tags}
onChange={ev => setTags (ev.target.value)} onChange={ev => setTags (ev.target.value)}
@@ -88,8 +88,7 @@ export default (({ tags, setTags, ...rest }: Props) => {
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}} }}/>
{...rest}/>
{focused && ( {focused && (
<TagSearchBox <TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0 suggestions={suggestionsVsbl && suggestions.length > 0
+1 -1
View File
@@ -42,7 +42,7 @@ export default (({ posts, onClick }: Props) => {
layoutId={layoutId} layoutId={layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow', className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform', 'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500', (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => { onLayoutAnimationStart={() => {
@@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
return ( return (
<input <input
{...rest}
className={cn ('border rounded p-2', className)} className={cn ('border rounded p-2', className)}
type="datetime-local" type="datetime-local"
value={local} value={local}
@@ -42,6 +43,5 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
setLocal (v) setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null) onChange?.(v ? (new Date (v)).toISOString () : null)
}} }}
onBlur={onBlur} onBlur={onBlur}/>)
{...rest}/>)
}) satisfies FC<Props> }) satisfies FC<Props>
@@ -5,6 +5,7 @@ import { Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -118,13 +119,15 @@ export default (({ children }: Props) => {
closeActive (active?.kind !== 'confirm' && null) closeActive (active?.kind !== 'confirm' && null)
}}> }}>
{active && ( {active && (
<DialogContent> <DialogContent className="px-6 pb-6 pt-7">
<DialogTitle>{active.options.title}</DialogTitle> <DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && ( {active.options.description && (
<DialogDescription asChild> <DialogDescription asChild>
<div>{active.options.description}</div> <div>{active.options.description}</div>
</DialogDescription>)} </DialogDescription>)}
</DialogHeader>
<DialogFooter> <DialogFooter>
{active.kind === 'confirm' && ( {active.kind === 'confirm' && (
+29 -16
View File
@@ -4,34 +4,47 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva (
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", [
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join (' '),
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
}, },
size: { size: {
default: "h-10 px-4 py-2", default: 'h-10 px-4 py-2',
sm: "h-9 rounded-md px-3", sm: 'h-9 rounded-md px-3',
lg: "h-11 rounded-md px-8", lg: 'h-11 rounded-md px-8',
icon: "h-10 w-10", icon: 'h-10 w-10',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} })
)
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+7 -5
View File
@@ -50,14 +50,16 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close <DialogPrimitive.Close
className={cn ( className={cn (
'absolute right-4 top-4 rounded-full p-1', 'absolute left-4 top-4 rounded-full p-1',
'text-muted-foreground opacity-70 transition-opacity', 'text-slate-500 transition-colors',
'hover:bg-accent hover:text-accent-foreground hover:opacity-100', 'hover:bg-slate-200 hover:text-slate-900',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}> 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
<span className="sr-only">Close</span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
@@ -1,9 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
DialogTitle } from '@/components/ui/dialog' DialogDescription,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
@@ -16,10 +19,16 @@ type Props = { visible: boolean
export default ({ visible, onVisibleChange, setUser }: Props) => { export default ({ visible, onVisibleChange, setUser }: Props) => {
const dialogue = useDialogue ()
const [inputCode, setInputCode] = useState ('') const [inputCode, setInputCode] = useState ('')
const handleTransfer = async () => { const handleTransfer = async () => {
if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.'))) if (!(await dialogue.confirm ({
title: '引継ぎを行ってもよろしいですか?',
description: '現在のアカウントからはログアウトされます.',
confirmText: '引継ぐ',
variant: 'danger' })))
return return
try try
@@ -44,14 +53,18 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent> <DialogContent className="px-6 pp-6 pt-7">
<DialogTitle></DialogTitle> <DialogHeader className="pl-8">
<div className="flex gap-2"> <DialogTitle></DialogTitle>
<Input placeholder="引継ぎコードを入力" <DialogDescription asChild>
value={inputCode} <div className="flex gap-2">
onChange={ev => setInputCode (ev.target.value)}/> <Input placeholder="引継ぎコードを入力"
<Button onClick={handleTransfer}></Button> value={inputCode}
</div> onChange={ev => setInputCode (ev.target.value)}/>
<Button onClick={handleTransfer}></Button>
</div>
</DialogDescription>
</DialogHeader>
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)
} }
@@ -1,6 +1,10 @@
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
@@ -14,11 +18,20 @@ type Props = { visible: boolean
export default ({ visible, onVisibleChange, user, setUser }: Props) => { export default ({ visible, onVisibleChange, user, setUser }: Props) => {
const dialogue = useDialogue ()
const handleChange = async () => { const handleChange = async () => {
if (!(user)) if (!(user))
return return
if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) if (!(await dialogue.confirm ({
title: '引継ぎコードを再発行しますか?',
description: (
<div>
<p></p>
</div>),
confirmText: '再発行',
variant: 'danger' })))
return return
const data = await apiPost<{ code: string }> ('/users/code/renew', { }, const data = await apiPost<{ code: string }> ('/users/code/renew', { },
@@ -33,21 +46,26 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent> <DialogContent className="px-6 pb-6 pt-7">
<DialogTitle></DialogTitle> <DialogHeader className="pl-8">
<div> <DialogTitle></DialogTitle>
<p></p>
<div className="m-2">{user?.inheritanceCode}</div> <DialogDescription asChild>
<p className="mt-1 text-sm text-red-500"> <div>
! <p></p>
</p> <div className="m-2">{user?.inheritanceCode}</div>
<div className="my-4"> <p className="mt-1 text-sm text-destructive">
<Button onClick={handleChange} !
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"> </p>
</div>
</Button> </DialogDescription>
</div> </DialogHeader>
</div>
<DialogFooter>
<Button onClick={handleChange} variant="destructive">
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)
} }
+8 -2
View File
@@ -8,6 +8,7 @@ import TagLink from '@/components/TagLink'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
@@ -35,6 +36,8 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
export default (() => { export default (() => {
const dialogue = useDialogue ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
@@ -66,8 +69,11 @@ export default (() => {
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => { const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
e.preventDefault () e.preventDefault ()
if (!(confirm (`${ change.title.current || change.url.current }』を版 ${ if (!(await dialogue.confirm ({
change.versionNo } に差戻します.\nよろしいですか?`))) title: '差戻の確認',
description: `${ change.title.current || change.url.current }』を版 ${
change.versionNo } に差戻します.\nよろしいですか?`,
confirmText: '差戻' })))
return return
try try