コミットを比較

..
5 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 3bb95880d4 AI: address review on PR #478 2026-11-14 18:00:00 +09:00
みてるぞ 61135de727 Merge branch 'main' into feature/401 2026-11-14 17:39:57 +09:00
Codex Night Worker 39efe8caae AI: address review on PR #478 2026-11-14 17:38:20 +09:00
テツノニジカとCodex Night Worker e13a52db9a AI: #454 広場のタグ検索,スマホ版だとサゼストが途中で見切れる問題 (#456)
Refs #454

## Automated implementation (requires human review)
[SidebarComponent.tsx](frontend/src/components/layout/SidebarComponent.tsx) の1行を修正しました。`overflow-y-auto` を `md` 以上に限定し、スマホでサゼストがサイドバーの領域外で切られる原因を取り除きました。

差分を確認済みです。指定に従い、テスト・lint・ビルドは未実行です。実画面でのスマホ表示とPC表示の確認も未実施です。

## Automated review (not a guarantee of correctness)
```text
The change restricts sidebar scrolling to medium and larger viewports. No actionable regression was identified in the current diff.

```

## Verification
- Tests/builds/migrations were not authorized and were not invoked by the controller.
- Codex was instructed not to run them; its command transcript should be inspected.
- Do not merge before manual acceptance and required checks.

---------

Co-authored-by: Codex Night Worker <codex-night@localhost>
Reviewed-on: #456
Co-authored-by: テツノニジカ <969+codex-bot@noreply.localhost>
2026-11-14 17:09:38 +09:00
Codex Night Worker 23ee19eee7 AI: address issue #401 2026-11-14 02:59:35 +09:00
15個のファイルの変更、92行の追加、26行の削除
+6 -6
ファイルの表示
@@ -37,7 +37,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
history_join_required = order[0] == 'updated_at' || updated_between.any?(&:present?) needs_history_join = order[0] == 'updated_at' || updated_between.any?(&:present?)
q = q =
filtered_posts(locale) filtered_posts(locale)
@@ -57,8 +57,8 @@ class PostsController < ApplicationController
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
count_scope = q count_scope = q
if history_join_required if needs_history_join
history_times = PostVersion.all history_times = HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS latest_at').group(:post_id) .select('post_id, MAX(created_at) AS latest_at').group(:post_id)
q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " + q = q.joins("LEFT JOIN (#{ history_times.to_sql }) history_times " +
'ON history_times.post_id = posts.id') 'ON history_times.post_id = posts.id')
@@ -72,7 +72,7 @@ class PostsController < ApplicationController
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1]) q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end end
count_scope = q if updated_between.any?(&:present?) count_scope = q if updated_between.any?(&:present?)
if history_join_required if needs_history_join
q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at") q = q.select('posts.*', "#{ updated_at_all_sql } AS updated_at")
end end
@@ -92,10 +92,10 @@ class PostsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
latest_version_times = latest_version_times =
if history_join_required if needs_history_join
{ } { }
else else
PostVersion.where(post_id: posts.map(&:id)) HistoryAccess.visible(PostVersion.where(post_id: posts.map(&:id)))
.group(:post_id).maximum(:created_at) .group(:post_id).maximum(:created_at)
end end
+1
ファイルの表示
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
private private
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
+6 -5
ファイルの表示
@@ -8,11 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
TagDescription.where(language_code: locale.language_code) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names) .includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc) tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -40,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String) return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language) return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names) names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language }) .where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name| candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:) WikiPageRepr.autolink_group(locale, id:, name:, language:)
end) end)
render json: candidates render json: candidates
@@ -410,7 +411,7 @@ class WikiPagesController < ApplicationController
end end
def tag_description_by_title locale, title def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names) HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code, .find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title }) tag_names: { language_code: locale.language_code, name: title })
end end
+23
ファイルの表示
@@ -147,6 +147,29 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
scope.where(Arel.sql(sql(scope.klass, row))) scope.where(Arel.sql(sql(scope.klass, row)))
end end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = []) def self.blob_visible?(blob, seen = [])
return true if admin? return true if admin?
return false if seen.include?(blob.id) return false if seen.include?(blob.id)
+1
ファイルの表示
@@ -44,6 +44,7 @@ module TagGroupRepr
end end
def one locale, group def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first many(locale, [group]).first
end end
+1
ファイルの表示
@@ -5,6 +5,7 @@ module WikiPageRepr
module_function module_function
def tag_description locale, description def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
end end
end end
it 'keeps discarded history private while exposing the current description' do it 'denies the same discarded description through Counts, the facade and the ordinary API' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history', get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' } mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -22,8 +22,8 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do
'excluded_count' => 2, 'versions' => []) 'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path| ["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' } get path, params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:not_found)
expect(response.parsed_body.fetch('body')).to eq(description.body) expect(response.body).not_to include('private body')
end end
get '/sitemap_entries/descriptions', params: { locale: 'ja' } get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
+10 -8
ファイルの表示
@@ -69,38 +69,40 @@ RSpec.describe 'Post history privacy', type: :request do
sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous' sign_in_as(create(:user, role.to_sym)) unless role == 'anonymous'
end end
it 'uses the latest version timestamp for display regardless of role' do it 'uses only authorised history timestamps for display' do
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id } row = response.parsed_body.fetch('posts').find { _1.fetch('id') == post_record.id }
expected = origin + 4.days expected = origin + (role == 'admin' ? 4 : 3).days
expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected) expect(Time.zone.parse(row.fetch('updated_at'))).to eq(expected)
end end
it 'uses the latest version timestamp for date bounds and count regardless of role' do it 'uses the same visibility for date bounds and count' do
get '/posts', params: { updated_from: (origin + 4.days).iso8601, get '/posts', params: { updated_from: (origin + 4.days).iso8601,
updated_to: (origin + 4.days).iso8601 } updated_to: (origin + 4.days).iso8601 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
ids = response.parsed_body.fetch('posts').pluck('id') ids = response.parsed_body.fetch('posts').pluck('id')
expect(ids).to eq([post_record.id]) expect(ids).to eq(role == 'admin' ? [post_record.id] : [])
expect(response.parsed_body.fetch('count')).to eq(ids.length) expect(response.parsed_body.fetch('count')).to eq(ids.length)
end end
it 'sorts using the latest version timestamps in both directions regardless of role' do it 'sorts using authorised timestamps in both directions' do
['asc', 'desc'].each do |direction| ['asc', 'desc'].each do |direction|
get '/posts', params: { order: "updated_at:#{ direction }" } get '/posts', params: { order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expected = [other_post.id, post_record.id] expected = role == 'admin' ? [other_post.id, post_record.id] :
[post_record.id, other_post.id]
expected.reverse! if direction == 'desc' expected.reverse! if direction == 'desc'
expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected) expect(response.parsed_body.fetch('posts').pluck('id')).to eq(expected)
end end
end end
it 'applies latest version timestamp ordering before pagination regardless of role' do it 'applies authorised timestamp ordering before pagination' do
get '/posts', params: { order: 'updated_at:desc', limit: 1 } get '/posts', params: { order: 'updated_at:desc', limit: 1 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([post_record.id]) expected = role == 'admin' ? post_record.id : other_post.id
expect(response.parsed_body.fetch('posts').pluck('id')).to eq([expected])
expect(response.parsed_body.fetch('count')).to eq(2) expect(response.parsed_body.fetch('count')).to eq(2)
end end
+15
ファイルの表示
@@ -153,6 +153,21 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setDisabled (true) setDisabled (true)
try try
{ {
const titleMissing = !title?.trim ()
const thumbnailMissing =
!titleMissing
&& !(post.thumbnail?.trim () || post.thumbnailBase?.trim ())
if ((titleMissing || thumbnailMissing)
&& !(await dialogue.confirm ({
title: titleMissing
? label ('edit.titleMissingConfirmation')
: label ('edit.thumbnailMissingConfirmation'),
confirmText: label ('shared:actions.update'),
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close') })))
return
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null, duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
+10
ファイルの表示
@@ -70,6 +70,16 @@ describe ('SidebarComponent', () => {
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
}) })
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
renderSidebar ()
// jsdom has no layout; guard the responsive classes responsible for clipping.
const sidebar = screen.getByText ('Sidebar body')
expect (sidebar).not.toHaveClass ('overflow-y-auto')
expect (sidebar).toHaveClass ('md:overflow-y-auto')
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => { it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700') localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn () const onWidthChange = vi.fn ()
+1 -1
ファイルの表示
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
</style> </style>
</Helmet> </Helmet>
<div className="h-full overflow-y-auto p-4 sidebar"> <div className="h-full md:overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
+3
ファイルの表示
@@ -1,4 +1,5 @@
{ {
"_untranslated": ["edit.titleMissingConfirmation", "edit.thumbnailMissingConfirmation"],
"list": { "list": {
"tagline": "{{brand}}", "tagline": "{{brand}}",
"empty": "ミンタㇻ タ ネン カ イサㇺ", "empty": "ミンタㇻ タ ネン カ イサㇺ",
@@ -31,6 +32,8 @@
"showExternal": "ソイタㇰ ソㇱ アヌカレ" "showExternal": "ソイタㇰ ソㇱ アヌカレ"
}, },
"edit": { "edit": {
"titleMissingConfirmation": "タイトルが設定されてゐません.更新しますか?",
"thumbnailMissingConfirmation": "サムネールが設定されてゐません.更新しますか?",
"validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……", "validationFailed": "アシㇼ カㇻ カ エアイカㇷ゚……",
"conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!", "conflictTitle": "モㇱマ クㇽ アシㇼ カㇻ ワ アン!",
"conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。", "conflictDescription": "モㇱマ イカㇻ クㇽ エエトコ アシㇼ カㇻ ワ アン。",
+2
ファイルの表示
@@ -22,6 +22,8 @@
"question": "表示しますか?", "showExternal": "外部ページを表示" "question": "表示しますか?", "showExternal": "外部ページを表示"
}, },
"edit": { "edit": {
"titleMissingConfirmation": "タイトルが設定されてゐません.更新しますか?",
"thumbnailMissingConfirmation": "サムネールが設定されてゐません.更新しますか?",
"validationFailed": "更新はできなかったよ……", "validationFailed": "更新はできなかったよ……",
"conflictTitle": "競合が発生しました.", "conflictTitle": "競合が発生しました.",
"conflictDescription": "ほかの耕作員が先に更新してゐます.", "conflictDescription": "ほかの耕作員が先に更新してゐます.",
+9 -2
ファイルの表示
@@ -35,6 +35,10 @@ const renderPage = () =>
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
tagsApi.fetchTag.mockReset ()
api.apiPut.mockReset ()
api.isApiError.mockReset ()
toastApi.toast.mockReset ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
}) })
@@ -51,8 +55,11 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' })) const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] })
api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] })) tagsApi.fetchTag
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()