このコミットが含まれているのは:
2026-07-16 20:05:09 +09:00
コミット 90d8d3ff08
27個のファイルの変更725行の追加257行の削除
+68 -7
ファイルの表示
@@ -1,4 +1,5 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'date'
require 'mini_magick' require 'mini_magick'
require 'stringio' require 'stringio'
@@ -237,15 +238,43 @@ class Post < ApplicationRecord
value = raw_value.to_s.strip value = raw_value.to_s.strip
return nil if value.blank? return nil if value.blank?
if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}\z/) if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/))
return Time.zone.local(value[0, 4].to_i, year = match[1].to_i
value[5, 2].to_i, month = match[2].to_i
value[8, 2].to_i, day = match[3].to_i
value[11, 2].to_i, hour = match[4].to_i
value[14, 2].to_i) minute = match[5].to_i
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
return Time.zone.local(year, month, day, hour, minute)
end end
Time.iso8601(value).in_time_zone match =
value.match(
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/ \
'(?::(\d{2})(?:\.(\d+))?)?' \
'(Z|[+-]\d{2}:?\d{2})\z/')
return nil if match.nil?
year = match[1].to_i
month = match[2].to_i
day = match[3].to_i
hour = match[4].to_i
minute = match[5].to_i
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8]
return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
return nil unless valid_original_created_offset?(offset)
Time.new(
year,
month,
day,
hour,
minute,
second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
normalise_original_created_offset(offset)).in_time_zone
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
@@ -254,6 +283,38 @@ class Post < ApplicationRecord
value.sec.zero? && value.nsec.zero? value.sec.zero? && value.nsec.zero?
end end
def valid_original_created_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def valid_original_created_offset? value
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return true if value == 'Z'
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def parse_original_created_nanoseconds value
return 0 if value.blank?
digits = value[0, 9].ljust(9, '0')
Integer(digits, 10)
end
def normalise_original_created_offset value
return '+00:00' if value == 'Z'
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end
def skip_original_created_validation? def skip_original_created_validation?
return false if new_record? return false if new_record?
return false if will_save_change_to_original_created_from? return false if will_save_change_to_original_created_from?
-2
ファイルの表示
@@ -75,8 +75,6 @@ class PostCreator
duration = @attributes[:duration] duration = @attributes[:duration]
return nil if duration.blank? return nil if duration.blank?
return duration.to_i if duration.is_a?(Numeric) && duration.to_i.positive?
value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間') value = Tag.time_to_ms!(duration.to_s, tag_name: '動画時間')
raise VideoMsParseError unless value.positive? raise VideoMsParseError unless value.positive?
+23 -4
ファイルの表示
@@ -157,7 +157,15 @@ class PostImportPreviewer
def initial_attributes row def initial_attributes row
attributes = row[:attributes]&.stringify_keys || { } attributes = row[:attributes]&.stringify_keys || { }
FIELDS.to_h { |field| [field, attributes[field].to_s] } FIELDS.to_h { |field|
value = attributes[field]
normalised =
if field == 'duration'
normalise_duration_attribute(value)
else
value.to_s
end
[field, normalised] }
end end
def initial_provenance row def initial_provenance row
@@ -256,9 +264,20 @@ class PostImportPreviewer
end end
def sanitise_metadata_duration value def sanitise_metadata_duration value
return nil unless value.is_a?(Numeric) return nil unless value.is_a?(String)
value.positive? ? value.to_i : nil value.presence
end
def normalise_duration_attribute value
return '' if value.nil?
return value if value.is_a?(String)
milliseconds = Integer(value, exception: false)
return value.to_s if milliseconds.nil? || milliseconds <= 0
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
end end
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
@@ -459,7 +478,7 @@ class PostImportPreviewer
def parse_duration value, errors def parse_duration value, errors
return nil if value.blank? return nil if value.blank?
value.is_a?(Numeric) ? value.to_i : Tag.time_to_ms!(value.to_s, tag_name: '動画時間') Tag.time_to_ms!(value.to_s, tag_name: '動画時間')
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
errors[:video_ms] = ['動画時間の記法が不正です.'] errors[:video_ms] = ['動画時間の記法が不正です.']
nil nil
+2 -1
ファイルの表示
@@ -5,9 +5,10 @@ class PostImportRowNormaliser
'thumbnail_base', 'thumbnail_base',
'original_created_from', 'original_created_from',
'original_created_before', 'original_created_before',
'duration',
'tags', 'tags',
'parent_post_ids'].freeze 'parent_post_ids'].freeze
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze FLEXIBLE_FIELDS = ['video_ms'].freeze
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
def self.normalise! rows, allow_warning_fields: false def self.normalise! rows, allow_warning_fields: false
+43 -1
ファイルの表示
@@ -1,4 +1,5 @@
require 'time' require 'time'
require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
@@ -30,7 +31,7 @@ class PostMetadataFetcher
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url], Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
original_created_from: serialise_time(created_range&.first), original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last), original_created_before: serialise_time(created_range&.last),
duration: duration&.to_f&.then { _1.positive? ? (_1 * 1_000).round : nil }, duration: serialise_duration(duration),
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -54,12 +55,16 @@ class PostMetadataFetcher
when /\A(\d{4})-(\d{2})\z/ when /\A(\d{4})-(\d{2})\z/
year = Regexp.last_match(1).to_i year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i month = Regexp.last_match(2).to_i
return nil unless month.between?(1, 12)
from = Time.zone.local(year, month, 1) from = Time.zone.local(year, month, 1)
[from, from + 1.month] [from, from + 1.month]
when /\A(\d{4})-(\d{2})-(\d{2})\z/ when /\A(\d{4})-(\d{2})-(\d{2})\z/
year = Regexp.last_match(1).to_i year = Regexp.last_match(1).to_i
month = Regexp.last_match(2).to_i month = Regexp.last_match(2).to_i
day = Regexp.last_match(3).to_i day = Regexp.last_match(3).to_i
return nil unless Date.valid_date?(year, month, day)
from = Time.zone.local(year, month, day) from = Time.zone.local(year, month, day)
[from, from + 1.day] [from, from + 1.day]
else else
@@ -84,6 +89,9 @@ class PostMetadataFetcher
second = match[6]&.to_i || 0 second = match[6]&.to_i || 0
fraction = match[7] fraction = match[7]
offset = match[8] offset = match[8]
return nil unless valid_timestamp_components?(year, month, day, hour, minute, second)
return nil unless valid_offset?(offset)
nanoseconds = parse_nanoseconds(fraction) nanoseconds = parse_nanoseconds(fraction)
timestamp = timestamp =
if offset.present? if offset.present?
@@ -117,6 +125,37 @@ class PostMetadataFetcher
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end end
def self.serialise_duration value
seconds = Float(value)
return nil unless seconds.positive?
milliseconds = (seconds * 1_000).round
seconds_string = (milliseconds / 1_000.0).to_s
seconds_string.end_with?('.0') ? seconds_string.delete_suffix('.0') : seconds_string
rescue ArgumentError, TypeError
nil
end
def self.valid_timestamp_components? year, month, day, hour, minute, second
return false unless Date.valid_date?(year, month, day)
return false unless hour.between?(0, 23)
return false unless minute.between?(0, 59)
return false unless second.between?(0, 59)
true
end
def self.valid_offset? value
return true if value.nil? || value == 'Z'
match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
return false if match.nil?
hours = match[2].to_i
minutes = match[3].to_i
hours.between?(0, 23) && minutes.between?(0, 59)
end
def self.serialise_time value def self.serialise_time value
return nil if value.nil? return nil if value.nil?
@@ -128,5 +167,8 @@ class PostMetadataFetcher
:parse_timestamp_range, :parse_timestamp_range,
:parse_nanoseconds, :parse_nanoseconds,
:parse_offset, :parse_offset,
:serialise_duration,
:valid_timestamp_components?,
:valid_offset?,
:serialise_time :serialise_time
end end
+21
ファイルの表示
@@ -336,5 +336,26 @@ RSpec.describe Post, type: :model do
expect(post).to be_valid expect(post).to be_valid
end end
it 'rejects invalid calendar dates and invalid hours' do
invalid_dates = [
'2024-02-31T12:00',
'2023-02-29T12:00',
'2024-02-29T24:00'
]
invalid_dates.each do |value|
post = described_class.new(
title: 'title',
url: 'https://example.com/post',
original_created_from: value
)
expect(post).to be_invalid
expect(post.errors[:original_created_from]).to eq(
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
)
end
end
end end
end end
+44
ファイルの表示
@@ -115,6 +115,50 @@ RSpec.describe 'Post imports API', type: :request do
end end
describe 'POST /posts/import' do describe 'POST /posts/import' do
it 'keeps the duration string contract through preview, validate, and import' do
sign_in_as(member)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: nil,
duration: '2.5',
tags: '動画'
)
post '/posts/import/preview', params: {
source: 'https://example.com/video'
}
preview_row = json.fetch('rows').first
expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import/validate', params: {
rows: [{
sourceRow: preview_row.fetch('source_row'),
url: preview_row.fetch('url'),
attributes: preview_row.fetch('attributes'),
provenance: preview_row.fetch('provenance'),
tagSources: preview_row.fetch('tag_sources'),
metadataUrl: preview_row.fetch('metadata_url')
}],
changed_row: -1
}
validated_row = json.fetch('rows').first
expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
post '/posts/import', params: {
rows: [{
sourceRow: validated_row.fetch('source_row'),
url: validated_row.fetch('url'),
attributes: validated_row.fetch('attributes'),
provenance: validated_row.fetch('provenance'),
tagSources: validated_row.fetch('tag_sources'),
metadataUrl: validated_row.fetch('metadata_url')
}]
}
expect(response).to have_http_status(:ok)
expect(Post.order(:id).last.video_ms).to eq(2_500)
end
it 'returns a formal skipped result for an existing post' do it 'returns a formal skipped result for an existing post' do
existing = create(:post, url: 'https://example.com/existing') existing = create(:post, url: 'https://example.com/existing')
sign_in_as(member) sign_in_as(member)
+2 -2
ファイルの表示
@@ -68,7 +68,7 @@ RSpec.describe PostImportPreviewer do
allow(PostMetadataFetcher).to receive(:fetch).and_return( allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'metadata title', title: 'metadata title',
thumbnail_base: 'https://example.com/thumb.jpg', thumbnail_base: 'https://example.com/thumb.jpg',
duration: 2_000, duration: '2',
tags: 'known-tag' tags: 'known-tag'
) )
@@ -79,7 +79,7 @@ RSpec.describe PostImportPreviewer do
expect(result.fetch(:attributes)).to include( expect(result.fetch(:attributes)).to include(
'title' => 'metadata title', 'title' => 'metadata title',
'thumbnail_base' => 'https://example.com/thumb.jpg', 'thumbnail_base' => 'https://example.com/thumb.jpg',
'duration' => 2_000, 'duration' => '2',
'tags' => 'known-tag' 'tags' => 'known-tag'
) )
expect(result.fetch(:field_warnings)).not_to have_key('tags') expect(result.fetch(:field_warnings)).not_to have_key('tags')
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ RSpec.describe PostImportRowNormaliser do
sourceRow: '1', sourceRow: '1',
url: 'https://example.com/post', url: 'https://example.com/post',
metadataUrl: 'https://example.com/post', metadataUrl: 'https://example.com/post',
attributes: { title: 'title', duration: 1_000 }, attributes: { title: 'title', duration: '1' },
provenance: { url: 'manual', title: 'automatic' }, provenance: { url: 'manual', title: 'automatic' },
tagSources: { automatic: 'tag', manual: '' } tagSources: { automatic: 'tag', manual: '' }
}.deep_merge(overrides) }.deep_merge(overrides)
@@ -21,7 +21,7 @@ RSpec.describe PostImportRowNormaliser do
'source_row' => 1, 'source_row' => 1,
'url' => 'https://example.com/post', 'url' => 'https://example.com/post',
'metadata_url' => 'https://example.com/post', 'metadata_url' => 'https://example.com/post',
'attributes' => { 'title' => 'title', 'duration' => 1_000 }, 'attributes' => { 'title' => 'title', 'duration' => '1' },
'provenance' => { 'url' => 'manual', 'title' => 'automatic' }, 'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' } 'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
} }
+38
ファイルの表示
@@ -68,4 +68,42 @@ RSpec.describe PostMetadataFetcher do
expect(result.fetch(:original_created_from)).to be_nil expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil expect(result.fetch(:original_created_before)).to be_nil
end end
it 'returns nil dates for invalid calendar dates and invalid hour values' do
invalid_day = fetch_with_published_time('2024-02-31T12:00Z')
invalid_leap = fetch_with_published_time('2023-02-29T12:00Z')
invalid_hour = fetch_with_published_time('2024-02-29T24:00Z')
[invalid_day, invalid_leap, invalid_hour].each do |result|
expect(result.fetch(:original_created_from)).to be_nil
expect(result.fetch(:original_created_before)).to be_nil
end
end
it 'accepts a valid leap-day timestamp at minute precision' do
result = fetch_with_published_time('2024-02-29T12:34Z')
expect(result.fetch(:original_created_from)).to eq('2024-02-29T12:34:00Z')
expect(result.fetch(:original_created_before)).to eq('2024-02-29T12:35:00Z')
end
it 'serialises metadata duration as the same seconds string contract used by forms' do
html = <<~HTML
<html><head>
<meta property="article:published_time" content="2024-02-03T12:34:56.123+02:30">
<meta property="og:video:duration" content="2.5">
</head></html>
HTML
uri = URI.parse('https://example.com/video')
allow(Preview::UrlSafety).to receive(:validate)
.with('https://example.com/video')
.and_return([uri, ['8.8.8.8']])
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
.and_return(title: 'title', image_url: nil)
result = described_class.fetch('https://example.com/video')
expect(result.fetch(:duration)).to eq('2.5')
end
end end
+5 -5
ファイルの表示
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/lib/dialogues/useDialogue', () => ({
useDialogue: () => ({ default: () => ({
choice: vi.fn (), choice: vi.fn (),
}), }),
})) }))
@@ -78,15 +78,15 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5) expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2] const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } }) fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument () expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
fireEvent.change (tags, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, target: { value: '動画 general-tag' },
}) })
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5) expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
}) })
}) })
+22 -40
ファイルの表示
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import PostDurationField from '@/components/posts/PostDurationField'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass, msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -157,32 +158,20 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タイトル */} <PostTextField
<FormField label="タイトル"> label="タイトル"
{({ invalid }) => ( value={title ?? ''}
<input disabled={disabled}
type="text" onChange={setTitle}/>
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)}
</FormField>
{/* 親投稿 */} <PostTextField
<FormField label="親投稿" messages={fieldErrors.parentPostIds}> label="親投稿"
{({ describedBy, invalid }) => ( value={parentPostIds}
<input disabled={disabled}
type="text" errors={fieldErrors.parentPostIds}
disabled={disabled} onChange={setParentPostIds}/>
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} <PostTagsField
<PostFormTagsArea
disabled={disabled} disabled={disabled}
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}
@@ -197,19 +186,12 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && ( {videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}> <PostDurationField
{({ invalid }) => ( value={duration}
<input disabled={disabled}
type="number" errors={fieldErrors.videoMs}
min="0.001" onChange={setDuration}/>)}
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
+28
ファイルの表示
@@ -0,0 +1,28 @@
import PostTextField from '@/components/posts/PostTextField'
import type { FC } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
disabled?: boolean }
const PostDurationField: FC<Props> = (
{ value,
onChange,
errors,
disabled },
) => (
<PostTextField
label="動画時間"
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"
placeholder="例: 2 / 2.5 / 1:23"/>
)
export default PostDurationField
+29
ファイルの表示
@@ -0,0 +1,29 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
const PostTagsField: FC<Props> = (
{ tags,
setTags,
warnings,
errors,
...rest },
) => (
<div className="space-y-2">
<PostFormTagsArea
{...rest}
tags={tags}
setTags={setTags}
errors={errors}/>
<FieldWarning messages={warnings}/>
</div>)
export default PostTagsField
+49
ファイルの表示
@@ -0,0 +1,49 @@
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
disabled?: boolean
type?: string
placeholder?: string
className?: string
after?: ReactNode }
const PostTextField: FC<Props> = (
{ label,
value,
onChange,
warnings,
errors,
disabled,
type = 'text',
placeholder,
className,
after },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
type={type}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid, className)}/>
<FieldWarning messages={warnings}/>
{after}
</>)}
</FormField>)
export default PostTextField
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
url: string url: string
alt?: string alt?: string
className?: string } className?: string }
const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className = 'h-16 w-16' }) => { const PostThumbnailPreview: FC<Props> = (
{ url, alt = 'サムネール', className = 'h-16 w-16' },
) => {
const [failed, setFailed] = useState (false) const [failed, setFailed] = useState (false)
useEffect (() => { useEffect (() => {
@@ -46,4 +47,4 @@ const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className =
onError={() => setFailed (true)}/>) onError={() => setFailed (true)}/>)
} }
export default ThumbnailPreview export default PostThumbnailPreview
+14 -58
ファイルの表示
@@ -3,10 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning' import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField' import PostDurationField from '@/components/posts/PostDurationField'
import TextArea from '@/components/common/TextArea' import PostTagsField from '@/components/posts/PostTagsField'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import PostTextField from '@/components/posts/PostTextField'
import { inputClass } from '@/lib/utils' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import type { FC } from 'react' import type { FC } from 'react'
@@ -153,13 +153,13 @@ const PostImportRowForm: FC<Props> = (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]"> <div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start"> <div className="space-y-3 md:sticky md:top-0 md:self-start">
<ThumbnailPreview <PostThumbnailPreview
url={draft.thumbnailBase} url={draft.thumbnailBase}
className="h-28 w-28"/> className="h-28 w-28"/>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<PostImportTextField <PostTextField
label="URL" label="URL"
value={draft.url} value={draft.url}
warnings={displayRow.fieldWarnings.url} warnings={displayRow.fieldWarnings.url}
@@ -167,7 +167,7 @@ const PostImportRowForm: FC<Props> = (
displayRow.validationErrors.url, displayRow.validationErrors.url,
displayRow.importErrors?.url)} displayRow.importErrors?.url)}
onChange={value => update ('url', value)}/> onChange={value => update ('url', value)}/>
<PostImportTextField <PostTextField
label="タイトル" label="タイトル"
value={draft.title} value={draft.title}
warnings={displayRow.fieldWarnings.title} warnings={displayRow.fieldWarnings.title}
@@ -175,7 +175,7 @@ const PostImportRowForm: FC<Props> = (
displayRow.validationErrors.title, displayRow.validationErrors.title,
displayRow.importErrors?.title)} displayRow.importErrors?.title)}
onChange={value => update ('title', value)}/> onChange={value => update ('title', value)}/>
<PostImportTextField <PostTextField
label="サムネール基底 URL" label="サムネール基底 URL"
value={draft.thumbnailBase} value={draft.thumbnailBase}
warnings={displayRow.fieldWarnings.thumbnailBase} warnings={displayRow.fieldWarnings.thumbnailBase}
@@ -195,8 +195,7 @@ const PostImportRowForm: FC<Props> = (
displayRow.importErrors?.originalCreatedAt, displayRow.importErrors?.originalCreatedAt,
displayRow.importErrors?.originalCreatedFrom, displayRow.importErrors?.originalCreatedFrom,
displayRow.importErrors?.originalCreatedBefore)}/> displayRow.importErrors?.originalCreatedBefore)}/>
<PostImportTextField <PostDurationField
label="動画時間"
value={draft.duration} value={draft.duration}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.duration, displayRow.validationErrors.duration,
@@ -204,15 +203,15 @@ const PostImportRowForm: FC<Props> = (
displayRow.importErrors?.duration, displayRow.importErrors?.duration,
displayRow.importErrors?.videoMs)} displayRow.importErrors?.videoMs)}
onChange={value => update ('duration', value)}/> onChange={value => update ('duration', value)}/>
<PostImportAreaField <PostTagsField
label="タグ" tags={draft.tags}
value={draft.tags} setTags={value => update ('tags', value)}
warnings={displayRow.fieldWarnings.tags} warnings={displayRow.fieldWarnings.tags}
errors={groupedMessages ( errors={groupedMessages (
displayRow.validationErrors.tags, displayRow.validationErrors.tags,
displayRow.importErrors?.tags)} displayRow.importErrors?.tags)}
onChange={value => update ('tags', value)}/> rows={4}/>
<PostImportTextField <PostTextField
label="親投稿" label="親投稿"
value={draft.parentPostIds} value={draft.parentPostIds}
errors={groupedMessages ( errors={groupedMessages (
@@ -229,49 +228,6 @@ const PostImportRowForm: FC<Props> = (
</>) </>)
} }
const PostImportTextField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<input
value={value}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
const PostImportAreaField = (
{ label, value, warnings, errors, onChange }: {
label: string
value: string
warnings?: string[]
errors?: string[]
onChange: (value: string) => void },
) => (
<FormField label={label} messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
value={value}
rows={4}
onChange={ev => onChange (ev.target.value)}
aria-describedby={describedBy}
invalid={invalid}
className="h-auto"/>
<FieldWarning messages={warnings}/>
</>)}
</FormField>)
export default PostImportRowForm export default PostImportRowForm
export { buildDraft } export { buildDraft }
export type { Draft as PostImportRowDraft } export type { Draft as PostImportRowDraft }
+7 -7
ファイルの表示
@@ -1,9 +1,9 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn, originalCreatedAtString } from '@/lib/utils' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { canEditReviewRow } from '@/lib/postImportSession'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -39,7 +39,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">#{row.sourceRow}</div> <div className="text-sm font-medium">#{row.sourceRow}</div>
</div> </div>
<ThumbnailPreview <PostThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
className="h-16 w-16"/> className="h-16 w-16"/>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
@@ -69,7 +69,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created' || editDisabled === true}> disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button> </Button>
</div> </div>
@@ -80,7 +80,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
'space-y-3 rounded-lg border p-4 md:hidden', 'space-y-3 rounded-lg border p-4 md:hidden',
'transition-shadow hover:shadow-sm')}> 'transition-shadow hover:shadow-sm')}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ThumbnailPreview <PostThumbnailPreview
url={String (row.attributes.thumbnailBase ?? '')} url={String (row.attributes.thumbnailBase ?? '')}
className="h-20 w-20 shrink-0"/> className="h-20 w-20 shrink-0"/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
@@ -110,7 +110,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created' || editDisabled === true}> disabled={!(canEditReviewRow (row)) || editDisabled === true}>
</Button> </Button>
</div> </div>
+36
ファイルの表示
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { creatableImportRows, import { creatableImportRows,
canEditResultRow,
canEditReviewRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow, mergeValidatedImportRow,
@@ -161,6 +165,38 @@ describe ('post import row state', () => {
expect (resultRepairMode ([complete])).toBe ('all') expect (resultRepairMode ([complete])).toBe ('all')
}) })
it ('classifies editable and retryable rows by terminal and recoverable state', () => {
const ready = buildPostImportRow ()
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 })
const hardFailed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['failed'] } })
const pendingInvalid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })
const pendingValid = buildPostImportRow ({
importStatus: 'pending',
recoverable: true })
expect (canEditReviewRow (ready)).toBe (true)
expect (canEditReviewRow (skipped)).toBe (false)
expect (canEditReviewRow (hardFailed)).toBe (false)
expect (canEditResultRow (pendingInvalid)).toBe (true)
expect (canRetryResultRow (pendingInvalid)).toBe (false)
expect (canRetryResultRow (pendingValid)).toBe (true)
})
it ('detects missing, duplicate, and extra source rows exactly', () => {
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 2 }])).toBe (true)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 1 }])).toBe (false)
expect (hasExactSourceRows ([1, 2], [{ sourceRow: 1 }, { sourceRow: 3 }])).toBe (false)
})
it ('merges only the validated source row and preserves other row edits', () => { it ('merges only the validated source row and preserves other row edits', () => {
const edited = buildPostImportRow ({ const edited = buildPostImportRow ({
sourceRow: 1, sourceRow: 1,
+31
ファイルの表示
@@ -83,6 +83,24 @@ export const resultRepairMode = (
: 'all' : 'all'
export const canEditReviewRow = (row: PostImportRow): boolean =>
!(row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| (row.importStatus === 'failed' && row.recoverable !== true))
export const canEditResultRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors (row)))
export const canRetryResultRow = (row: PostImportRow): boolean =>
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && !(hasValidationErrors (row))))
export const resultRowMessages = (row: PostImportRow): string[] => export const resultRowMessages = (row: PostImportRow): string[] =>
[...new Set ([ [...new Set ([
...Object.values (row.validationErrors ?? { }).flat (), ...Object.values (row.validationErrors ?? { }).flat (),
@@ -95,6 +113,19 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
...row.baseWarnings])] ...row.baseWarnings])]
export const hasExactSourceRows = (
expected: number[],
actual: Array<{ sourceRow: number }>,
): boolean => {
if (expected.length !== actual.length)
return false
const expectedSorted = [...expected].sort ((a, b) => a - b)
const actualSorted = actual.map (_1 => _1.sourceRow).sort ((a, b) => a - b)
return expectedSorted.every ((value, index) => value === actualSorted[index])
}
export const replaceImportRow = ( export const replaceImportRow = (
rows: PostImportRow[], rows: PostImportRow[],
nextRow: PostImportRow, nextRow: PostImportRow,
+2
ファイルの表示
@@ -16,6 +16,7 @@ describe ('post import storage', () => {
it ('round-trips a valid session and source draft', () => { it ('round-trips a valid session and source draft', () => {
const row = buildPostImportRow ({ const row = buildPostImportRow ({
attributes: { duration: '2.5' },
recoverable: true, recoverable: true,
importStatus: 'pending', importStatus: 'pending',
validationErrors: { title: ['invalid'] } }) validationErrors: { title: ['invalid'] } })
@@ -32,6 +33,7 @@ describe ('post import storage', () => {
version: 2, version: 2,
source: skipped.url, source: skipped.url,
rows: [{ rows: [{
attributes: { duration: '2.5' },
recoverable: true, recoverable: true,
importStatus: 'pending' }, importStatus: 'pending' },
{ {
+50 -1
ファイルの表示
@@ -10,6 +10,7 @@ import {
import PostImportResultPage from '@/pages/posts/PostImportResultPage' import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { originalCreatedAtString } from '@/lib/utils'
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -56,7 +57,10 @@ describe ('PostImportResultPage', () => {
importErrors: { base: ['hard failed'] } }), importErrors: { base: ['hard failed'] } }),
buildPostImportRow ({ buildPostImportRow ({
sourceRow: 3, sourceRow: 3,
attributes: { title: 'created row' }, attributes: {
title: 'created row',
originalCreatedFrom: '2024-01-01T00:00:00Z',
originalCreatedBefore: '2024-01-02T00:00:00Z' },
importStatus: 'created', importStatus: 'created',
createdPostId: 3, createdPostId: 3,
fieldWarnings: { fieldWarnings: {
@@ -77,6 +81,9 @@ describe ('PostImportResultPage', () => {
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1) expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.')) expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument () .toBeInTheDocument ()
expect (screen.getByText (
originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'),
)).toBeInTheDocument ()
}) })
it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => { it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => {
@@ -317,4 +324,46 @@ describe ('PostImportResultPage', () => {
expect (saved?.rows[0]?.recoverable).toBe (true) expect (saved?.rows[0]?.recoverable).toBe (true)
}) })
}) })
it ('restores the failed row when the retry import response is incomplete', async () => {
savePostImportSession ('result-retry-missing-row', {
source: '',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
const saved = loadPostImportSession ('result-retry-missing-row')
expect (saved?.rows[0]?.importStatus).toBe ('failed')
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
})
}) })
+53 -18
ファイルの表示
@@ -17,6 +17,9 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft, import { clearPostImportSourceDraft,
canEditResultRow,
canRetryResultRow,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
loadPostImportSession, loadPostImportSession,
mergeImportResults, mergeImportResults,
@@ -28,6 +31,7 @@ import { clearPostImportSourceDraft,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
savePostImportSession } from '@/lib/postImportSession' savePostImportSession } from '@/lib/postImportSession'
import { originalCreatedAtString } from '@/lib/utils'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
@@ -145,6 +149,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
if (currentSession == null) if (currentSession == null)
return { saved: false, row: null } return { saved: false, row: null }
if (!(canEditResultRow (row)))
return { saved: false, row: null }
const baseRow = const baseRow =
resetRequested resetRequested
@@ -274,10 +280,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
sessionRef.current = pendingSession sessionRef.current = pendingSession
setSession (pendingSession) setSession (pendingSession)
persistSession (pendingSession) persistSession (pendingSession)
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows rows: requestedRows.map (row => ({
.filter (row => row.importStatus !== 'created')
.map (row => ({
sourceRow: row.sourceRow, sourceRow: row.sourceRow,
url: row.url, url: row.url,
attributes: row.attributes, attributes: row.attributes,
@@ -285,8 +290,22 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })), metadataUrl: row.metadataUrl })),
changed_row: -1 }) changed_row: -1 })
const validatedTarget = initialisePreviewRows (validated.rows) const validatedRows = initialisePreviewRows (validated.rows)
.find (_1 => _1.sourceRow === sourceRow) if (!(hasExactSourceRows (requestedRows.map (row => row.sourceRow), validatedRows)))
{
const latest = sessionRef.current ?? pendingSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
const validatedTarget = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (validatedTarget == null) if (validatedTarget == null)
{ {
const latest = sessionRef.current ?? pendingSession const latest = sessionRef.current ?? pendingSession
@@ -302,17 +321,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
return return
} }
const latestAfterValidate = sessionRef.current ?? pendingSession const latestAfterValidate = sessionRef.current ?? pendingSession
const validatedRows = mergeValidatedImportRow ( const mergedValidatedRows = mergeValidatedImportRow (
latestAfterValidate.rows, latestAfterValidate.rows,
validatedTarget) validatedTarget)
const nextSession = { const nextSession = {
...latestAfterValidate, ...latestAfterValidate,
rows: validatedRows, rows: mergedValidatedRows,
repairMode: resultRepairMode (validatedRows) } repairMode: resultRepairMode (mergedValidatedRows) }
sessionRef.current = nextSession sessionRef.current = nextSession
setSession (nextSession) setSession (nextSession)
persistSession (nextSession) persistSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow) const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null) if (target == null)
{ {
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow) const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
@@ -347,6 +366,20 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
provenance: target.provenance, provenance: target.provenance,
tagSources: target.tagSources, tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] }) metadataUrl: target.metadataUrl }] })
if (!(hasExactSourceRows ([sourceRow], result.rows)))
{
const latest = sessionRef.current ?? nextSession
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? nextSession const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows) const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row => const recoverableRows = result.rows.filter (row =>
@@ -454,15 +487,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="space-y-3"> <div className="space-y-3">
{session.rows.map (row => { {session.rows.map (row => {
const displayStatus = displayPostImportStatus (row) const displayStatus = displayPostImportStatus (row)
const hasValidationErrors = Object.keys (row.validationErrors).length > 0 const canEdit = canEditResultRow (row)
const canEdit = const canRetry = canRetryResultRow (row)
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors))
const canRetry =
row.recoverable === true
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
&& !(hasValidationErrors)
return ( return (
<div <div
key={row.sourceRow} key={row.sourceRow}
@@ -480,6 +506,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url} {row.url}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '') || 'タグなし'}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div>
<FieldWarning messages={resultRowWarnings (row)}/> <FieldWarning messages={resultRowWarnings (row)}/>
<FieldError messages={resultRowMessages (row)}/> <FieldError messages={resultRowMessages (row)}/>
</div> </div>
+37
ファイルの表示
@@ -141,4 +141,41 @@ describe ('PostImportReviewPage', () => {
}) })
expect (api.apiPost).toHaveBeenCalledTimes (1) expect (api.apiPost).toHaveBeenCalledTimes (1)
}) })
it ('does not navigate to result when import omits a requested source row', async () => {
savePostImportSession ('review-missing-import-row', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-import-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '登録結果が不完全でした' }))
})
expect (screen.queryByText ('RESULT ROUTE')).not.toBeInTheDocument ()
})
}) })
+22 -6
ファイルの表示
@@ -13,7 +13,9 @@ import { apiPost } from '@/lib/api'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { loadPostImportSession, import { loadPostImportSession,
canEditReviewRow,
creatableImportRows, creatableImportRows,
hasExactSourceRows,
initialisePreviewRows, initialisePreviewRows,
mergeImportResults, mergeImportResults,
mergeValidatedImportRow, mergeValidatedImportRow,
@@ -106,6 +108,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const currentSession = sessionRef.current const currentSession = sessionRef.current
if (currentSession == null) if (currentSession == null)
return { saved: false, row: null } return { saved: false, row: null }
if (!(canEditReviewRow (row)))
return { saved: false, row: null }
const baseRow = const baseRow =
resetRequested resetRequested
@@ -234,8 +238,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
const expectedSourceRows = currentSession.rows const expectedSourceRows = currentSession.rows
.filter (row => row.importStatus !== 'created') .filter (row => row.importStatus !== 'created')
.map (row => row.sourceRow) .map (row => row.sourceRow)
const validatedSourceRows = new Set (validatedRows.map (_1 => _1.sourceRow)) if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
if (expectedSourceRows.some (_1 => !(validatedSourceRows.has (_1))))
{ {
toast ({ title: '再検証結果が不完全でした' }) toast ({ title: '再検証結果が不完全でした' })
return return
@@ -254,6 +257,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
void editRow (firstInvalid) void editRow (firstInvalid)
return return
} }
const validatedSession = {
...latestAfterValidate,
rows: mergedRows,
repairMode: resultRepairMode (mergedRows) }
sessionRef.current = validatedSession
setSession (validatedSession)
const savedValidated = savePostImportSession (sessionId, validatedSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(savedValidated))
return
const result = await apiPost<{ const result = await apiPost<{
created: number created: number
@@ -267,10 +280,13 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
metadataUrl: row.metadataUrl })) }) metadataUrl: row.metadataUrl })) })
const latestAfterImport = sessionRef.current ?? { const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
...latestAfterValidate, if (!(hasExactSourceRows (expectedImportRows, result.rows)))
rows: mergedRows, {
repairMode: resultRepairMode (mergedRows) } toast ({ title: '登録結果が不完全でした' })
return
}
const latestAfterImport = sessionRef.current ?? validatedSession
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows) const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row => const recoverableRows = result.rows.filter (row =>
row.status === 'failed' row.status === 'failed'
+24 -8
ファイルの表示
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -39,8 +41,8 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[2], { target: { value: '1 2' } }) fireEvent.change (textboxes[3], { target: { value: '1 2' } })
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } }) fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => { await waitFor (() => {
@@ -63,17 +65,19 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[3] const tags = screen.getAllByRole ('textbox')[4]
fireEvent.change (tags, { target: { value: '動画' } }) fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } }) fireEvent.change (
screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23'),
{ target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } }) fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument () expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
fireEvent.change (tags, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, target: { value: '動画 general-tag' },
}) })
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5) expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
}) })
it ('shows 422 validation errors for post fields', async () => { it ('shows 422 validation errors for post fields', async () => {
@@ -96,11 +100,23 @@ describe ('PostNewPage', () => {
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } }) fireEvent.change (textboxes[4], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument () expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true') expect (screen.getAllByRole ('textbox')[4]).toHaveAttribute ('aria-invalid', 'true')
})
it ('shares the common post field components with the import form', () => {
const newPage = readFileSync ('src/pages/posts/PostNewPage.tsx', 'utf8')
const importForm = readFileSync ('src/components/posts/import/PostImportRowForm.tsx', 'utf8')
expect (newPage).toContain ("@/components/posts/PostTextField")
expect (newPage).toContain ("@/components/posts/PostDurationField")
expect (newPage).toContain ("@/components/posts/PostTagsField")
expect (importForm).toContain ("@/components/posts/PostTextField")
expect (importForm).toContain ("@/components/posts/PostDurationField")
expect (importForm).toContain ("@/components/posts/PostTagsField")
}) })
}) })
+66 -89
ファイルの表示
@@ -2,19 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form' import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
@@ -138,30 +139,22 @@ const PostNewPage: FC<Props> = ({ user }) => {
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* URL */} <PostTextField
<FormField label="URL" messages={fieldErrors.url}> label="URL"
{({ describedBy, invalid }) => ( type="url"
<input type="url" value={url}
placeholder="例:https://www.nicovideo.jp/watch/..." errors={fieldErrors.url}
value={url} placeholder="例:https://www.nicovideo.jp/watch/..."
onChange={e => setURL (e.target.value)} onChange={setURL}/>
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */} <PostTextField
<FormField label="タイトル" messages={fieldErrors.title}> label="タイトル"
{({ describedBy, invalid }) => ( value={title}
<div className="space-y-2"> errors={fieldErrors.title}
<input type="text" disabled={titleLoading}
aria-describedby={describedBy} placeholder={titleLoading ? 'Loading...' : undefined}
aria-invalid={invalid} onChange={setTitle}
className={inputClass (invalid)} after={
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span> <span> URL </span>
<Button <Button
@@ -171,59 +164,51 @@ const PostNewPage: FC<Props> = ({ user }) => {
disabled={!(url) || titleLoading}> disabled={!(url) || titleLoading}>
</Button> </Button>
</div> </div>}/>
</div>)}
</FormField>
{/* サムネール */} <div className="space-y-2">
<FormField label="サムネール" messages={fieldErrors.thumbnail}> <div className="flex flex-wrap items-center gap-2 text-sm">
{({ describedBy, invalid }) => ( <span> URL </span>
<> <Button
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm"> type="button"
<span> URL </span> variant="outline"
<Button onClick={() => void fetchThumbnail ()}
type="button" disabled={!(url) || thumbnailLoading}>
variant="outline"
onClick={() => void fetchThumbnail ()} </Button>
disabled={!(url) || thumbnailLoading}> </div>
{thumbnailLoading && (
</Button> <p className="text-gray-500 text-sm">Loading...</p>)}
</div> <PostTextField
{thumbnailLoading && ( label="サムネール"
<p className="text-gray-500 text-sm">Loading...</p>)} value={thumbnailFile?.name ?? ''}
<input type="file" errors={fieldErrors.thumbnail}
accept="image/*" disabled
aria-describedby={describedBy} onChange={() => {}}/>
aria-invalid={invalid} <input
onChange={e => { type="file"
const file = e.target.files?.[0] accept="image/*"
if (file) onChange={e => {
{ const file = e.target.files?.[0]
setThumbnailFile (file) if (file)
setThumbnailPreview (URL.createObjectURL (file)) {
} setThumbnailFile (file)
}}/> setThumbnailPreview (URL.createObjectURL (file))
{thumbnailPreview && ( }
<img src={thumbnailPreview} }}/>
alt="preview" <PostThumbnailPreview
className="mt-2 max-h-48 rounded border"/>)} url={thumbnailPreview}
</>)} alt="preview"
</FormField> className="h-28 w-28"/>
</div>
{/* 親投稿 */} <PostTextField
<FormField label="親投稿" messages={fieldErrors.parentPostIds}> label="親投稿"
{({ describedBy, invalid }) => ( value={parentPostIds}
<input errors={fieldErrors.parentPostIds}
type="text" onChange={setParentPostIds}/>
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} <PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
@@ -233,19 +218,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */} {videoFlg && (
{(videoFlg && <PostDurationField
<FormField label="動画時間" messages={fieldErrors.videoMs}> value={duration}
{({ invalid }) => ( errors={fieldErrors.videoMs}
<input onChange={setDuration}/>)}
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}