Compare commits
3 Commits
5b50642756
...
d8b028a4dd
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b028a4dd | |||
| 49c82c626a | |||
| 35e5af2f9a |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +27,8 @@ 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)
|
||||||
|
version =
|
||||||
PostVersion.create!(
|
PostVersion.create!(
|
||||||
post:,
|
post:,
|
||||||
version_no: 1,
|
version_no: 1,
|
||||||
@@ -36,8 +41,20 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
created_by_user: post.uploaded_user
|
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(
|
||||||
|
post_record,
|
||||||
title: 'updated title',
|
title: 'updated title',
|
||||||
tags: 'nico:nico_tag'
|
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(
|
||||||
|
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}")
|
||||||
)
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
created_by_user:,
|
created_by_user:,
|
||||||
created_at:
|
created_at:
|
||||||
)
|
)
|
||||||
|
version =
|
||||||
TagVersion.create!(
|
TagVersion.create!(
|
||||||
tag: tag,
|
tag: tag,
|
||||||
version_no: version_no,
|
version_no: version_no,
|
||||||
@@ -35,8 +36,12 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
aliases: Array(aliases).join(' '),
|
aliases: Array(aliases).join(' '),
|
||||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
created_by_user: created_by_user,
|
created_by_user: created_by_user,
|
||||||
created_at: created_at
|
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
|
||||||
@@ -103,18 +103,24 @@ export default (({ post, onSave }: Props) => {
|
|||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
|
||||||
setDisabled (true)
|
setDisabled (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
await update ({ id: post.id, title, tags, parentPostIds,
|
await update ({ id: post.id, title, tags, parentPostIds,
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
{ baseVersionNo: post.versionNo })
|
{ baseVersionNo: post.versionNo })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
setDisabled (false)
|
setDisabled (false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setTags(tagsToStr (post.tags))
|
setTags(tagsToStr (post.tags))
|
||||||
}, [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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>{active.options.title}</DialogTitle>
|
<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' && (
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,8 +1,11 @@
|
|||||||
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,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
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'
|
||||||
@@ -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">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input placeholder="引継ぎコードを入力"
|
<Input placeholder="引継ぎコードを入力"
|
||||||
value={inputCode}
|
value={inputCode}
|
||||||
onChange={ev => setInputCode (ev.target.value)}/>
|
onChange={ev => setInputCode (ev.target.value)}/>
|
||||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
<Button onClick={handleTransfer}>引継ぐ</Button>
|
||||||
</div>
|
</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">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>引継ぎコード</DialogTitle>
|
<DialogTitle>引継ぎコード</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription asChild>
|
||||||
<div>
|
<div>
|
||||||
<p>あなたの引継ぎコードはこちらです:</p>
|
<p>あなたの引継ぎコードはこちらです:</p>
|
||||||
<div className="m-2">{user?.inheritanceCode}</div>
|
<div className="m-2">{user?.inheritanceCode}</div>
|
||||||
<p className="mt-1 text-sm text-red-500">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
このコードはほかの人には教えないでください!
|
このコードはほかの人には教えないでください!
|
||||||
</p>
|
</p>
|
||||||
<div className="my-4">
|
</div>
|
||||||
<Button onClick={handleChange}
|
</DialogDescription>
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400">
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleChange} variant="destructive">
|
||||||
引継ぎコード再発行
|
引継ぎコード再発行
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user