このコミットが含まれているのは:
@@ -2,6 +2,13 @@ class Post < ApplicationRecord
|
|||||||
require 'mini_magick'
|
require 'mini_magick'
|
||||||
require 'stringio'
|
require 'stringio'
|
||||||
|
|
||||||
|
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
|
||||||
|
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
|
||||||
|
'オリジナルの作成日時は分単位で入力してください.'.freeze
|
||||||
|
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
|
||||||
|
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
|
||||||
|
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
|
||||||
|
|
||||||
def self.resized_thumbnail_attachment(upload)
|
def self.resized_thumbnail_attachment(upload)
|
||||||
upload.rewind
|
upload.rewind
|
||||||
image = MiniMagick::Image.read(upload.read)
|
image = MiniMagick::Image.read(upload.read)
|
||||||
@@ -143,16 +150,17 @@ class Post < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def validate_original_created_range
|
def validate_original_created_range
|
||||||
f = original_created_from
|
f = parse_original_created_value(:original_created_from)
|
||||||
b = original_created_before
|
b = parse_original_created_value(:original_created_before)
|
||||||
return if f.blank? || b.blank?
|
return if f.nil? || b.nil?
|
||||||
|
|
||||||
f = Time.zone.parse(f) if String === f
|
if b <= f
|
||||||
b = Time.zone.parse(b) if String === b
|
errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
|
||||||
return if !(f) || !(b)
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if f >= b
|
if b - f < 1.minute
|
||||||
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
|
errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -175,4 +183,49 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
self.url = PostUrlNormaliser.normalise(url) || url.strip
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parse_original_created_value field
|
||||||
|
raw_value = public_send("#{ field }_before_type_cast")
|
||||||
|
value = public_send(field)
|
||||||
|
return nil if raw_value.blank? && value.blank?
|
||||||
|
|
||||||
|
time =
|
||||||
|
case raw_value
|
||||||
|
when String
|
||||||
|
parse_original_created_string(raw_value)
|
||||||
|
when Time, ActiveSupport::TimeWithZone
|
||||||
|
raw_value.in_time_zone
|
||||||
|
else
|
||||||
|
value&.in_time_zone
|
||||||
|
end
|
||||||
|
if time.nil?
|
||||||
|
errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
unless minute_precision_time?(time)
|
||||||
|
errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
|
||||||
|
end
|
||||||
|
time
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_original_created_string raw_value
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
Time.iso8601(value).in_time_zone
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def minute_precision_time? value
|
||||||
|
value.sec.zero? && value.nsec.zero?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -250,8 +250,7 @@ class PostImportPreviewer
|
|||||||
def sanitise_metadata_time value
|
def sanitise_metadata_time value
|
||||||
return nil unless value.is_a?(String)
|
return nil unless value.is_a?(String)
|
||||||
|
|
||||||
time = Time.iso8601(value)
|
Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
|
||||||
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
|
|
||||||
rescue ArgumentError, TypeError
|
rescue ArgumentError, TypeError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -81,26 +81,16 @@ class PostMetadataFetcher
|
|||||||
day = match[3].to_i
|
day = match[3].to_i
|
||||||
hour = match[4].to_i
|
hour = match[4].to_i
|
||||||
minute = match[5]&.to_i || 0
|
minute = match[5]&.to_i || 0
|
||||||
second = match[6]&.to_i || 0
|
|
||||||
fraction = match[7]
|
|
||||||
offset = match[8]
|
offset = match[8]
|
||||||
whole_second = second + fractional_seconds(fraction)
|
timestamp =
|
||||||
from =
|
|
||||||
if offset.present?
|
if offset.present?
|
||||||
Time.new(year, month, day, hour, minute, whole_second, parse_offset(offset)).in_time_zone
|
Time.new(year, month, day, hour, minute, 0, parse_offset(offset)).in_time_zone
|
||||||
else
|
else
|
||||||
Time.zone.local(year, month, day, hour, minute, whole_second)
|
Time.zone.local(year, month, day, hour, minute)
|
||||||
end
|
|
||||||
before =
|
|
||||||
if fraction.present?
|
|
||||||
from + (10**(-fraction.length))
|
|
||||||
elsif match[6].present?
|
|
||||||
from + 1.second
|
|
||||||
elsif match[5].present?
|
|
||||||
from + 1.minute
|
|
||||||
else
|
|
||||||
from + 1.hour
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
from = timestamp.change(sec: 0, nsec: 0)
|
||||||
|
before = from + 1.minute
|
||||||
[from, before]
|
[from, before]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -110,12 +100,6 @@ 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.fractional_seconds value
|
|
||||||
return 0 if value.blank?
|
|
||||||
|
|
||||||
Rational(value.to_i, 10**value.length)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.serialise_time value
|
def self.serialise_time value
|
||||||
return nil if value.nil?
|
return nil if value.nil?
|
||||||
|
|
||||||
@@ -126,6 +110,5 @@ class PostMetadataFetcher
|
|||||||
:original_created_range,
|
:original_created_range,
|
||||||
:parse_timestamp_range,
|
:parse_timestamp_range,
|
||||||
:parse_offset,
|
:parse_offset,
|
||||||
:fractional_seconds,
|
|
||||||
:serialise_time
|
:serialise_time
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ RSpec.describe 'Post imports API', type: :request do
|
|||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
|
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns original created datetime validation errors for minute precision' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import/validate', params: {
|
||||||
|
rows: [{
|
||||||
|
sourceRow: 1,
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
metadataUrl: 'https://example.com/post',
|
||||||
|
attributes: {
|
||||||
|
originalCreatedFrom: '2020-01-01T00:00:30Z',
|
||||||
|
originalCreatedBefore: '2020-01-01T00:01Z' },
|
||||||
|
provenance: {
|
||||||
|
url: 'manual',
|
||||||
|
originalCreatedFrom: 'manual',
|
||||||
|
originalCreatedBefore: 'manual' },
|
||||||
|
tagSources: { automatic: '', manual: '' }
|
||||||
|
}],
|
||||||
|
changed_row: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('rows').first.fetch('validation_errors')).to include(
|
||||||
|
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST /posts/import' do
|
describe 'POST /posts/import' do
|
||||||
|
|||||||
@@ -2206,6 +2206,89 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects second-bearing original created timestamps on POST /posts' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'invalid original created from',
|
||||||
|
url: 'https://example.com/invalid-original-created-from',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload,
|
||||||
|
original_created_from: '2020-01-01T00:00:01Z')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects fractional-second original created timestamps on POST /posts' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'invalid original created before',
|
||||||
|
url: 'https://example.com/invalid-original-created-before',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload,
|
||||||
|
original_created_before: '2020-01-01T00:00:00.123Z')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects original created ranges shorter than one minute on POST /posts' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'too short original created range',
|
||||||
|
url: 'https://example.com/too-short-original-created-range',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload,
|
||||||
|
original_created_from: '2020-01-01T00:00Z',
|
||||||
|
original_created_before: '2020-01-01T00:00:30Z')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'original_created_at' => ['オリジナルの作成日時の範囲は1分以上必要です.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts original created ranges that are exactly one minute on POST /posts' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'valid original created range',
|
||||||
|
url: 'https://example.com/valid-original-created-range',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload,
|
||||||
|
original_created_from: '2020-01-01T00:00Z',
|
||||||
|
original_created_before: '2020-01-01T00:01Z')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(Time.iso8601(json.fetch('original_created_from')))
|
||||||
|
.to eq(Time.iso8601('2020-01-01T00:00Z'))
|
||||||
|
expect(Time.iso8601(json.fetch('original_created_before')))
|
||||||
|
.to eq(Time.iso8601('2020-01-01T00:01Z'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
|
||||||
|
sign_in_as(member)
|
||||||
|
base_version = create_post_version_for!(post_record)
|
||||||
|
|
||||||
|
put "/posts/#{post_record.id}", params: post_write_params(
|
||||||
|
base_version_no: base_version.version_no,
|
||||||
|
title: 'updated title',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
original_created_from: 'not-a-time')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'tag versioning from post write actions' do
|
describe 'tag versioning from post write actions' do
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ RSpec.describe PostMetadataFetcher do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preserves an input offset and fractional-second precision' do
|
it 'rounds second and fractional-second timestamps down to one-minute ranges' do
|
||||||
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
|
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
|
||||||
|
|
||||||
expect(Time.iso8601(result.fetch(:original_created_from)))
|
expect(Time.iso8601(result.fetch(:original_created_from)))
|
||||||
.to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30'))
|
.to eq(Time.iso8601('2024-02-03T12:34:00+02:30'))
|
||||||
expect(
|
expect(
|
||||||
Time.iso8601(result.fetch(:original_created_before)) -
|
Time.iso8601(result.fetch(:original_created_before)) -
|
||||||
Time.iso8601(result.fetch(:original_created_from))
|
Time.iso8601(result.fetch(:original_created_from))
|
||||||
).to eq(0.001)
|
).to eq(60)
|
||||||
expect(result.fetch(:original_created_from)).to include('.123000000')
|
expect(result.fetch(:original_created_from)).not_to include('.')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'adds platform tags for known video URLs' do
|
it 'adds platform tags for known video URLs' do
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
|
|||||||
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
|
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
|
||||||
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
|
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
|
||||||
|
|
||||||
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
|
expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
|
||||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||||
|
expect (setBefore).toHaveBeenCalledWith (expect.stringMatching (
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/))
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('infers an exclusive before value on blur', () => {
|
it ('infers an exclusive before value on blur', () => {
|
||||||
@@ -38,7 +40,26 @@ describe ('PostOriginalCreatedTimeField', () => {
|
|||||||
const input = screen.getAllByDisplayValue ('')[0]
|
const input = screen.getAllByDisplayValue ('')[0]
|
||||||
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
|
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
|
||||||
|
|
||||||
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
|
const value = setBefore.mock.calls.at (-1)?.[0]
|
||||||
|
expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||||
|
expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ())
|
||||||
|
.toBe (60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('normalises second-bearing values to minute precision', () => {
|
||||||
|
const setFrom = vi.fn ()
|
||||||
|
const setBefore = vi.fn ()
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostOriginalCreatedTimeField
|
||||||
|
originalCreatedFrom="2026-01-01T00:00:05Z"
|
||||||
|
setOriginalCreatedFrom={setFrom}
|
||||||
|
originalCreatedBefore="2026-01-02T00:00:00.123Z"
|
||||||
|
setOriginalCreatedBefore={setBefore}/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z')
|
||||||
|
expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('resets both values', () => {
|
it ('resets both values', () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
@@ -19,7 +21,26 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
|||||||
setOriginalCreatedFrom,
|
setOriginalCreatedFrom,
|
||||||
originalCreatedBefore,
|
originalCreatedBefore,
|
||||||
setOriginalCreatedBefore,
|
setOriginalCreatedBefore,
|
||||||
errors }: Props) => (
|
errors }: Props) => {
|
||||||
|
useEffect (() => {
|
||||||
|
if (originalCreatedFrom == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const normalised = toMinutePrecisionIsoUtc (originalCreatedFrom)
|
||||||
|
if (normalised !== originalCreatedFrom)
|
||||||
|
setOriginalCreatedFrom (normalised)
|
||||||
|
}, [originalCreatedFrom, setOriginalCreatedFrom])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (originalCreatedBefore == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const normalised = toMinutePrecisionIsoUtc (originalCreatedBefore)
|
||||||
|
if (normalised !== originalCreatedBefore)
|
||||||
|
setOriginalCreatedBefore (normalised)
|
||||||
|
}, [originalCreatedBefore, setOriginalCreatedBefore])
|
||||||
|
|
||||||
|
return (
|
||||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
@@ -43,7 +64,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
|||||||
d.setDate (d.getDate () + 1)
|
d.setDate (d.getDate () + 1)
|
||||||
else
|
else
|
||||||
d.setMinutes (d.getMinutes () + 1)
|
d.setMinutes (d.getMinutes () + 1)
|
||||||
setOriginalCreatedBefore (d.toISOString ())
|
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||||
}}/>
|
}}/>
|
||||||
以降
|
以降
|
||||||
</div>
|
</div>
|
||||||
@@ -86,5 +107,6 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
|||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
|
}
|
||||||
|
|
||||||
export default PostOriginalCreatedTimeField
|
export default PostOriginalCreatedTimeField
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
|
|||||||
fireEvent.change (input, { target: { value: '' } })
|
fireEvent.change (input, { target: { value: '' } })
|
||||||
|
|
||||||
const first = handleChange.mock.calls[0]?.[0]
|
const first = handleChange.mock.calls[0]?.[0]
|
||||||
expect (new Date (first).getFullYear ()).toBe (2026)
|
expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)
|
||||||
|
expect (new Date (first).getUTCSeconds ()).toBe (0)
|
||||||
expect (handleChange).toHaveBeenLastCalledWith (null)
|
expect (handleChange).toHaveBeenLastCalledWith (null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
|
|||||||
const pad = (n: number): string => n.toString ().padStart (2, '0')
|
const pad = (n: number): string => n.toString ().padStart (2, '0')
|
||||||
|
|
||||||
|
|
||||||
const toDateTimeLocalValue = (d: Date) => {
|
const toDateTimeLocalValue = (value: Date) => {
|
||||||
const y = d.getFullYear ()
|
const y = value.getFullYear ()
|
||||||
const m = pad (d.getMonth () + 1)
|
const m = pad (value.getMonth () + 1)
|
||||||
const day = pad (d.getDate ())
|
const day = pad (value.getDate ())
|
||||||
const h = pad (d.getHours ())
|
const h = pad (value.getHours ())
|
||||||
const min = pad (d.getMinutes ())
|
const min = pad (value.getMinutes ())
|
||||||
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
|
return `${ y }-${ m }-${ day }T${ h }:${ min }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const toMinutePrecisionIsoUtc = (value: string) => {
|
||||||
|
const date = new Date (value)
|
||||||
|
if (Number.isNaN (date.getTime ()))
|
||||||
|
return value
|
||||||
|
|
||||||
|
const y = date.getUTCFullYear ()
|
||||||
|
const m = pad (date.getUTCMonth () + 1)
|
||||||
|
const day = pad (date.getUTCDate ())
|
||||||
|
const h = pad (date.getUTCHours ())
|
||||||
|
const min = pad (date.getUTCMinutes ())
|
||||||
|
return `${ y }-${ m }-${ day }T${ h }:${ min }Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,14 +61,16 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
|
|||||||
'focus:ring-2 focus:ring-blue-200']),
|
'focus:ring-2 focus:ring-blue-200']),
|
||||||
className)}
|
className)}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
step={60}
|
||||||
value={local}
|
value={local}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
onChange={ev => {
|
onChange={ev => {
|
||||||
const v = ev.target.value
|
const v = ev.target.value
|
||||||
setLocal (v)
|
setLocal (v)
|
||||||
onChange?.(v ? (new Date (v)).toISOString () : null)
|
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
|
||||||
}}
|
}}
|
||||||
onBlur={onBlur}/>)
|
onBlur={onBlur}/>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DateTimeField
|
export default DateTimeField
|
||||||
|
export { toMinutePrecisionIsoUtc }
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
|||||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||||
values.flatMap (value => value ?? [])
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
|
||||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||||
left.url === right.url
|
left.url === right.url
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
@@ -19,9 +19,9 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
|||||||
?? null
|
?? null
|
||||||
|
|
||||||
const summaryDate = (row: PostImportRow): string =>
|
const summaryDate = (row: PostImportRow): string =>
|
||||||
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore]
|
originalCreatedAtString (
|
||||||
.filter (_1 => typeof _1 === 'string' && _1 !== '')
|
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||||
.join (' ~ ')
|
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
||||||
@@ -52,7 +52,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
|
|||||||
{String (row.attributes.tags ?? '') || 'タグなし'}
|
{String (row.attributes.tags ?? '') || 'タグなし'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row) || '日時未取得'}
|
{summaryDate (row)}
|
||||||
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
|
||||||
</div>
|
</div>
|
||||||
{warning && (
|
{warning && (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react'
|
import { fireEvent, screen } from '@testing-library/react'
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { savePostImportSession } from '@/lib/postImportSession'
|
import { savePostImportSession } from '@/lib/postImportSession'
|
||||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||||
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import { buildUser } from '@/test/factories'
|
import { buildUser } from '@/test/factories'
|
||||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
@@ -14,17 +15,12 @@ const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
|
|
||||||
const ReviewLocation = () => {
|
|
||||||
const location = useLocation ()
|
|
||||||
return <div>{`review route ${ location.search }`}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderPage = () => renderWithProviders (
|
const renderPage = () => renderWithProviders (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/posts/import/:sessionId/result"
|
path="/posts/import/:sessionId/result"
|
||||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||||
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
|
<Route path="/posts/import/:sessionId/review" element={<PageTitle>review route</PageTitle>}/>
|
||||||
</Routes>,
|
</Routes>,
|
||||||
{ route: '/posts/import/session/result' })
|
{ route: '/posts/import/session/result' })
|
||||||
|
|
||||||
@@ -54,7 +50,7 @@ describe ('PostImportResultPage', () => {
|
|||||||
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
|
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('returns a retry validation error to the review dialogue route', async () => {
|
it ('keeps the result route and opens the dialogue on retry validation error', async () => {
|
||||||
const failed = buildPostImportRow ({
|
const failed = buildPostImportRow ({
|
||||||
importStatus: 'failed',
|
importStatus: 'failed',
|
||||||
importErrors: { base: ['old error'] } })
|
importErrors: { base: ['old error'] } })
|
||||||
@@ -71,7 +67,8 @@ describe ('PostImportResultPage', () => {
|
|||||||
renderPage ()
|
renderPage ()
|
||||||
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
|
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
|
||||||
|
|
||||||
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
|
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||||
const saved = JSON.parse (
|
const saved = JSON.parse (
|
||||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||||
@@ -79,4 +76,25 @@ describe ('PostImportResultPage', () => {
|
|||||||
importStatus: 'pending',
|
importStatus: 'pending',
|
||||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('keeps the dialogue open when result-page save validation fails', async () => {
|
||||||
|
const row = buildPostImportRow ({
|
||||||
|
importStatus: 'failed',
|
||||||
|
importErrors: { base: ['old error'] } })
|
||||||
|
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'failed' })
|
||||||
|
api.apiPost.mockResolvedValue ({
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
validationErrors: {
|
||||||
|
originalCreatedFrom: ['オリジナルの作成日時は分単位で入力してください.'] } })] })
|
||||||
|
|
||||||
|
renderPage ()
|
||||||
|
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
|
||||||
|
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
|
||||||
|
|
||||||
|
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('オリジナルの作成日時は分単位で入力してください.'))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,4 +75,23 @@ describe ('PostImportReviewPage', () => {
|
|||||||
skipReason: 'existing' })
|
skipReason: 'existing' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('keeps the dialogue open when save validation fails', async () => {
|
||||||
|
const row = buildPostImportRow ({ attributes: { title: 'title' } })
|
||||||
|
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||||
|
api.apiPost.mockResolvedValue ({
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: row.sourceRow,
|
||||||
|
validationErrors: {
|
||||||
|
originalCreatedAt: ['オリジナルの作成日時の範囲は1分以上必要です.'] } })] })
|
||||||
|
|
||||||
|
renderPage ()
|
||||||
|
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
|
||||||
|
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
|
||||||
|
|
||||||
|
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('オリジナルの作成日時の範囲は1分以上必要です.'))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする