広場投稿追加画面の刷新 (#399) #413
@@ -1,4 +1,5 @@
|
||||
class Post < ApplicationRecord
|
||||
require 'date'
|
||||
require 'mini_magick'
|
||||
require 'stringio'
|
||||
|
||||
@@ -237,15 +238,43 @@ class Post < ApplicationRecord
|
||||
value = raw_value.to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}\z/)
|
||||
return Time.zone.local(value[0, 4].to_i,
|
||||
value[5, 2].to_i,
|
||||
value[8, 2].to_i,
|
||||
value[11, 2].to_i,
|
||||
value[14, 2].to_i)
|
||||
if (match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/))
|
||||
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
|
||||
return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)
|
||||
|
||||
return Time.zone.local(year, month, day, hour, minute)
|
||||
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
|
||||
nil
|
||||
end
|
||||
@@ -254,6 +283,38 @@ class Post < ApplicationRecord
|
||||
value.sec.zero? && value.nsec.zero?
|
||||
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?
|
||||
return false if new_record?
|
||||
return false if will_save_change_to_original_created_from?
|
||||
|
||||
@@ -75,8 +75,6 @@ class PostCreator
|
||||
duration = @attributes[:duration]
|
||||
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: '動画時間')
|
||||
raise VideoMsParseError unless value.positive?
|
||||
|
||||
|
||||
@@ -157,7 +157,15 @@ class PostImportPreviewer
|
||||
|
||||
def initial_attributes row
|
||||
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
|
||||
|
||||
def initial_provenance row
|
||||
@@ -256,9 +264,20 @@ class PostImportPreviewer
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def preload_metadata! prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
|
||||
@@ -459,7 +478,7 @@ class PostImportPreviewer
|
||||
def parse_duration value, errors
|
||||
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
|
||||
errors[:video_ms] = ['動画時間の記法が不正です.']
|
||||
nil
|
||||
|
||||
@@ -5,9 +5,10 @@ class PostImportRowNormaliser
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
FLEXIBLE_FIELDS = ['duration', 'video_ms'].freeze
|
||||
FLEXIBLE_FIELDS = ['video_ms'].freeze
|
||||
ATTRIBUTE_FIELDS = (STRING_FIELDS + FLEXIBLE_FIELDS).freeze
|
||||
|
||||
def self.normalise! rows, allow_warning_fields: false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require 'time'
|
||||
require 'date'
|
||||
|
||||
class PostMetadataFetcher
|
||||
TIMESTAMP_PATTERN =
|
||||
@@ -30,7 +31,7 @@ class PostMetadataFetcher
|
||||
Preview::KnownSiteExtractor.thumbnail_url(uri) || metadata[:image_url],
|
||||
original_created_from: serialise_time(created_range&.first),
|
||||
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(' ') }
|
||||
end
|
||||
|
||||
@@ -54,12 +55,16 @@ class PostMetadataFetcher
|
||||
when /\A(\d{4})-(\d{2})\z/
|
||||
year = Regexp.last_match(1).to_i
|
||||
month = Regexp.last_match(2).to_i
|
||||
return nil unless month.between?(1, 12)
|
||||
|
||||
from = Time.zone.local(year, month, 1)
|
||||
[from, from + 1.month]
|
||||
when /\A(\d{4})-(\d{2})-(\d{2})\z/
|
||||
year = Regexp.last_match(1).to_i
|
||||
month = Regexp.last_match(2).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, from + 1.day]
|
||||
else
|
||||
@@ -84,6 +89,9 @@ class PostMetadataFetcher
|
||||
second = match[6]&.to_i || 0
|
||||
fraction = match[7]
|
||||
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)
|
||||
timestamp =
|
||||
if offset.present?
|
||||
@@ -117,6 +125,37 @@ class PostMetadataFetcher
|
||||
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
|
||||
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
|
||||
return nil if value.nil?
|
||||
|
||||
@@ -128,5 +167,8 @@ class PostMetadataFetcher
|
||||
:parse_timestamp_range,
|
||||
:parse_nanoseconds,
|
||||
:parse_offset,
|
||||
:serialise_duration,
|
||||
:valid_timestamp_components?,
|
||||
:valid_offset?,
|
||||
:serialise_time
|
||||
end
|
||||
|
||||
@@ -336,5 +336,26 @@ RSpec.describe Post, type: :model do
|
||||
|
||||
expect(post).to be_valid
|
||||
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
|
||||
|
||||
@@ -115,6 +115,50 @@ RSpec.describe 'Post imports API', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
existing = create(:post, url: 'https://example.com/existing')
|
||||
sign_in_as(member)
|
||||
|
||||
@@ -68,7 +68,7 @@ RSpec.describe PostImportPreviewer do
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'metadata title',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
duration: 2_000,
|
||||
duration: '2',
|
||||
tags: 'known-tag'
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ RSpec.describe PostImportPreviewer do
|
||||
expect(result.fetch(:attributes)).to include(
|
||||
'title' => 'metadata title',
|
||||
'thumbnail_base' => 'https://example.com/thumb.jpg',
|
||||
'duration' => 2_000,
|
||||
'duration' => '2',
|
||||
'tags' => 'known-tag'
|
||||
)
|
||||
expect(result.fetch(:field_warnings)).not_to have_key('tags')
|
||||
|
||||
@@ -6,7 +6,7 @@ RSpec.describe PostImportRowNormaliser do
|
||||
sourceRow: '1',
|
||||
url: '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' },
|
||||
tagSources: { automatic: 'tag', manual: '' }
|
||||
}.deep_merge(overrides)
|
||||
@@ -21,7 +21,7 @@ RSpec.describe PostImportRowNormaliser do
|
||||
'source_row' => 1,
|
||||
'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' },
|
||||
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
|
||||
}
|
||||
|
||||
@@ -68,4 +68,42 @@ RSpec.describe PostMetadataFetcher do
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
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
|
||||
|
||||
@@ -19,8 +19,8 @@ const toastApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
useDialogue: () => ({
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => ({
|
||||
choice: vi.fn (),
|
||||
}),
|
||||
}))
|
||||
@@ -78,15 +78,15 @@ describe ('PostEditForm', () => {
|
||||
|
||||
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]
|
||||
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, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { isApiError } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { updatePost } from '@/lib/posts'
|
||||
import { inputClass, msToTime } from '@/lib/utils'
|
||||
import { msToTime } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
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">
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル">
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={title ?? ''}
|
||||
onChange={e => setTitle (e.target.value)}/>)}
|
||||
</FormField>
|
||||
|
||||
{/* 親投稿 */}
|
||||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
value={parentPostIds}
|
||||
onChange={e => setParentPostIds (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
onChange={setTitle}/>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={parentPostIds}
|
||||
disabled={disabled}
|
||||
errors={fieldErrors.parentPostIds}
|
||||
onChange={setParentPostIds}/>
|
||||
|
||||
<PostTagsField
|
||||
disabled={disabled}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
@@ -197,19 +186,12 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{videoFlg && (
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
disabled={disabled}
|
||||
className={inputClass (invalid)}
|
||||
<PostDurationField
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}/>)}
|
||||
</FormField>)}
|
||||
disabled={disabled}
|
||||
errors={fieldErrors.videoMs}
|
||||
onChange={setDuration}/>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button type="submit" disabled={disabled}>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+4
-3
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
alt?: 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)
|
||||
|
||||
useEffect (() => {
|
||||
@@ -46,4 +47,4 @@ const ThumbnailPreview: FC<Props> = ({ url, alt = 'サムネール', className =
|
||||
onError={() => setFailed (true)}/>)
|
||||
}
|
||||
|
||||
export default ThumbnailPreview
|
||||
export default PostThumbnailPreview
|
||||
@@ -3,10 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FieldWarning from '@/components/common/FieldWarning'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import TextArea from '@/components/common/TextArea'
|
||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
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 type { FC } from 'react'
|
||||
|
||||
@@ -153,13 +153,13 @@ const PostImportRowForm: FC<Props> = (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<ThumbnailPreview
|
||||
<PostThumbnailPreview
|
||||
url={draft.thumbnailBase}
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="URL"
|
||||
value={draft.url}
|
||||
warnings={displayRow.fieldWarnings.url}
|
||||
@@ -167,7 +167,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url)}
|
||||
onChange={value => update ('url', value)}/>
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={draft.title}
|
||||
warnings={displayRow.fieldWarnings.title}
|
||||
@@ -175,7 +175,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title)}
|
||||
onChange={value => update ('title', value)}/>
|
||||
<PostImportTextField
|
||||
<PostTextField
|
||||
label="サムネール基底 URL"
|
||||
value={draft.thumbnailBase}
|
||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||
@@ -195,8 +195,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.importErrors?.originalCreatedAt,
|
||||
displayRow.importErrors?.originalCreatedFrom,
|
||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||
<PostImportTextField
|
||||
label="動画時間"
|
||||
<PostDurationField
|
||||
value={draft.duration}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.duration,
|
||||
@@ -204,15 +203,15 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.importErrors?.duration,
|
||||
displayRow.importErrors?.videoMs)}
|
||||
onChange={value => update ('duration', value)}/>
|
||||
<PostImportAreaField
|
||||
label="タグ"
|
||||
value={draft.tags}
|
||||
<PostTagsField
|
||||
tags={draft.tags}
|
||||
setTags={value => update ('tags', value)}
|
||||
warnings={displayRow.fieldWarnings.tags}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags)}
|
||||
onChange={value => update ('tags', value)}/>
|
||||
<PostImportTextField
|
||||
rows={4}/>
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={draft.parentPostIds}
|
||||
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 { buildDraft }
|
||||
export type { Draft as PostImportRowDraft }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { canEditReviewRow } from '@/lib/postImportSession'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -39,7 +39,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{row.sourceRow}</div>
|
||||
</div>
|
||||
<ThumbnailPreview
|
||||
<PostThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -69,7 +69,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created' || editDisabled === true}>
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="flex items-start gap-3">
|
||||
<ThumbnailPreview
|
||||
<PostThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
@@ -110,7 +110,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit, editDisabled }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={row.importStatus === 'created' || editDisabled === true}>
|
||||
disabled={!(canEditReviewRow (row)) || editDisabled === true}>
|
||||
編輯
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { creatableImportRows,
|
||||
canEditResultRow,
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
@@ -161,6 +165,38 @@ describe ('post import row state', () => {
|
||||
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', () => {
|
||||
const edited = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
|
||||
@@ -83,6 +83,24 @@ export const resultRepairMode = (
|
||||
: '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[] =>
|
||||
[...new Set ([
|
||||
...Object.values (row.validationErrors ?? { }).flat (),
|
||||
@@ -95,6 +113,19 @@ export const resultRowWarnings = (row: PostImportRow): string[] =>
|
||||
...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 = (
|
||||
rows: PostImportRow[],
|
||||
nextRow: PostImportRow,
|
||||
|
||||
@@ -16,6 +16,7 @@ describe ('post import storage', () => {
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
@@ -32,6 +33,7 @@ describe ('post import storage', () => {
|
||||
version: 2,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending' },
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
@@ -56,7 +57,10 @@ describe ('PostImportResultPage', () => {
|
||||
importErrors: { base: ['hard failed'] } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
attributes: { title: 'created row' },
|
||||
attributes: {
|
||||
title: 'created row',
|
||||
originalCreatedFrom: '2024-01-01T00:00:00Z',
|
||||
originalCreatedBefore: '2024-01-02T00:00:00Z' },
|
||||
importStatus: 'created',
|
||||
createdPostId: 3,
|
||||
fieldWarnings: {
|
||||
@@ -77,6 +81,9 @@ describe ('PostImportResultPage', () => {
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
|
||||
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
|
||||
.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', () => {
|
||||
@@ -317,4 +324,46 @@ describe ('PostImportResultPage', () => {
|
||||
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: '登録結果が不完全でした' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@ import { apiPost } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { clearPostImportSourceDraft,
|
||||
canEditResultRow,
|
||||
canRetryResultRow,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
loadPostImportSession,
|
||||
mergeImportResults,
|
||||
@@ -28,6 +31,7 @@ import { clearPostImportSourceDraft,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
savePostImportSession } from '@/lib/postImportSession'
|
||||
import { originalCreatedAtString } from '@/lib/utils'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
@@ -145,6 +149,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (!(canEditResultRow (row)))
|
||||
return { saved: false, row: null }
|
||||
|
||||
const baseRow =
|
||||
resetRequested
|
||||
@@ -274,10 +280,9 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
sessionRef.current = pendingSession
|
||||
setSession (pendingSession)
|
||||
persistSession (pendingSession)
|
||||
const requestedRows = pendingRows.filter (row => row.importStatus !== 'created')
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: pendingRows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => ({
|
||||
rows: requestedRows.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
@@ -285,8 +290,22 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
changed_row: -1 })
|
||||
const validatedTarget = initialisePreviewRows (validated.rows)
|
||||
.find (_1 => _1.sourceRow === sourceRow)
|
||||
const validatedRows = initialisePreviewRows (validated.rows)
|
||||
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)
|
||||
{
|
||||
const latest = sessionRef.current ?? pendingSession
|
||||
@@ -302,17 +321,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
return
|
||||
}
|
||||
const latestAfterValidate = sessionRef.current ?? pendingSession
|
||||
const validatedRows = mergeValidatedImportRow (
|
||||
const mergedValidatedRows = mergeValidatedImportRow (
|
||||
latestAfterValidate.rows,
|
||||
validatedTarget)
|
||||
const nextSession = {
|
||||
...latestAfterValidate,
|
||||
rows: validatedRows,
|
||||
repairMode: resultRepairMode (validatedRows) }
|
||||
rows: mergedValidatedRows,
|
||||
repairMode: resultRepairMode (mergedValidatedRows) }
|
||||
sessionRef.current = nextSession
|
||||
setSession (nextSession)
|
||||
persistSession (nextSession)
|
||||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
const target = mergedValidatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||||
if (target == null)
|
||||
{
|
||||
const restoredRows = replaceImportRow (latestAfterValidate.rows, originalRow)
|
||||
@@ -347,6 +366,20 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
provenance: target.provenance,
|
||||
tagSources: target.tagSources,
|
||||
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 mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
@@ -454,15 +487,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<div className="space-y-3">
|
||||
{session.rows.map (row => {
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const hasValidationErrors = Object.keys (row.validationErrors).length > 0
|
||||
const canEdit =
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed'
|
||||
|| (row.importStatus === 'pending' && hasValidationErrors))
|
||||
const canRetry =
|
||||
row.recoverable === true
|
||||
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
|
||||
&& !(hasValidationErrors)
|
||||
const canEdit = canEditResultRow (row)
|
||||
const canRetry = canRetryResultRow (row)
|
||||
return (
|
||||
<div
|
||||
key={row.sourceRow}
|
||||
@@ -480,6 +506,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{row.url}
|
||||
</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)}/>
|
||||
<FieldError messages={resultRowMessages (row)}/>
|
||||
</div>
|
||||
|
||||
@@ -141,4 +141,41 @@ describe ('PostImportReviewPage', () => {
|
||||
})
|
||||
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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,9 @@ import { apiPost } from '@/lib/api'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { loadPostImportSession,
|
||||
canEditReviewRow,
|
||||
creatableImportRows,
|
||||
hasExactSourceRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
@@ -106,6 +108,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const currentSession = sessionRef.current
|
||||
if (currentSession == null)
|
||||
return { saved: false, row: null }
|
||||
if (!(canEditReviewRow (row)))
|
||||
return { saved: false, row: null }
|
||||
|
||||
const baseRow =
|
||||
resetRequested
|
||||
@@ -234,8 +238,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
const expectedSourceRows = currentSession.rows
|
||||
.filter (row => row.importStatus !== 'created')
|
||||
.map (row => row.sourceRow)
|
||||
const validatedSourceRows = new Set (validatedRows.map (_1 => _1.sourceRow))
|
||||
if (expectedSourceRows.some (_1 => !(validatedSourceRows.has (_1))))
|
||||
if (!(hasExactSourceRows (expectedSourceRows, validatedRows)))
|
||||
{
|
||||
toast ({ title: '再検証結果が不完全でした' })
|
||||
return
|
||||
@@ -254,6 +257,16 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
void editRow (firstInvalid)
|
||||
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<{
|
||||
created: number
|
||||
@@ -267,10 +280,13 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })) })
|
||||
const latestAfterImport = sessionRef.current ?? {
|
||||
...latestAfterValidate,
|
||||
rows: mergedRows,
|
||||
repairMode: resultRepairMode (mergedRows) }
|
||||
const expectedImportRows = processableImportRows (mergedRows).map (row => row.sourceRow)
|
||||
if (!(hasExactSourceRows (expectedImportRows, result.rows)))
|
||||
{
|
||||
toast ({ title: '登録結果が不完全でした' })
|
||||
return
|
||||
}
|
||||
const latestAfterImport = sessionRef.current ?? validatedSession
|
||||
const mergedResults = mergeImportResults (latestAfterImport.rows, result.rows)
|
||||
const recoverableRows = result.rows.filter (row =>
|
||||
row.status === 'failed'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -39,8 +41,8 @@ describe ('PostNewPage', () => {
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[2], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: '1 2' } })
|
||||
fireEvent.change (textboxes[4], { target: { value: 'tag1 tag2' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
@@ -63,17 +65,19 @@ describe ('PostNewPage', () => {
|
||||
|
||||
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 (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' } })
|
||||
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
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 () => {
|
||||
@@ -96,11 +100,23 @@ describe ('PostNewPage', () => {
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
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: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('投稿内容を確認してください.')).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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import Form from '@/components/common/Form'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
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 { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
@@ -138,30 +139,22 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
<PageTitle>広場に投稿を追加する</PageTitle>
|
||||
<FieldError messages={baseErrors}/>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL" messages={fieldErrors.url}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input type="url"
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
<PostTextField
|
||||
label="URL"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setURL (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
errors={fieldErrors.url}
|
||||
placeholder="例:https://www.nicovideo.jp/watch/..."
|
||||
onChange={setURL}/>
|
||||
|
||||
{/* タイトル */}
|
||||
<FormField label="タイトル" messages={fieldErrors.title}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<div className="space-y-2">
|
||||
<input type="text"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
value={title}
|
||||
placeholder={titleLoading ? 'Loading...' : ''}
|
||||
onChange={ev => setTitle (ev.target.value)}
|
||||
disabled={titleLoading}/>
|
||||
errors={fieldErrors.title}
|
||||
disabled={titleLoading}
|
||||
placeholder={titleLoading ? 'Loading...' : undefined}
|
||||
onChange={setTitle}
|
||||
after={
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
@@ -171,15 +164,10 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
disabled={!(url) || titleLoading}>
|
||||
取得
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</FormField>
|
||||
</div>}/>
|
||||
|
||||
{/* サムネール */}
|
||||
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>必要なタイミングで URL から取得できます.</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -191,10 +179,15 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
</div>
|
||||
{thumbnailLoading && (
|
||||
<p className="text-gray-500 text-sm">Loading...</p>)}
|
||||
<input type="file"
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
value={thumbnailFile?.name ?? ''}
|
||||
errors={fieldErrors.thumbnail}
|
||||
disabled
|
||||
onChange={() => {}}/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file)
|
||||
@@ -203,27 +196,19 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
setThumbnailPreview (URL.createObjectURL (file))
|
||||
}
|
||||
}}/>
|
||||
{thumbnailPreview && (
|
||||
<img src={thumbnailPreview}
|
||||
<PostThumbnailPreview
|
||||
url={thumbnailPreview}
|
||||
alt="preview"
|
||||
className="mt-2 max-h-48 rounded border"/>)}
|
||||
</>)}
|
||||
</FormField>
|
||||
className="h-28 w-28"/>
|
||||
</div>
|
||||
|
||||
{/* 親投稿 */}
|
||||
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
value={parentPostIds}
|
||||
onChange={e => setParentPostIds (e.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
errors={fieldErrors.parentPostIds}
|
||||
onChange={setParentPostIds}/>
|
||||
|
||||
{/* タグ */}
|
||||
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
<PostTagsField tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
|
||||
|
||||
{/* オリジナルの作成日時 */}
|
||||
<PostOriginalCreatedTimeField
|
||||
@@ -233,19 +218,11 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||
errors={fieldErrors.originalCreatedAt}/>
|
||||
|
||||
{/* 動画時間 */}
|
||||
{(videoFlg &&
|
||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
{videoFlg && (
|
||||
<PostDurationField
|
||||
value={duration}
|
||||
onChange={e => setDuration (e.target.value)}
|
||||
aria-invalid={invalid}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>)}
|
||||
errors={fieldErrors.videoMs}
|
||||
onChange={setDuration}/>)}
|
||||
|
||||
{/* 送信 */}
|
||||
<Button onClick={handleSubmit}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする