Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -1,8 +1,10 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
import { textContentOf } from '@/test/text'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
@@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => {
|
||||
const titleInput = screen.getByDisplayValue ('manual title')
|
||||
|
||||
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
|
||||
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
|
||||
const reset = actions.at (-1)?.find (action => textContentOf (action.label) === '変更をリセット')
|
||||
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
|
||||
await act (async () => {
|
||||
await reset?.onSelect ()
|
||||
@@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => {
|
||||
expect (titleInput).toHaveValue ('')
|
||||
expect (onSave).not.toHaveBeenCalled ()
|
||||
|
||||
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
|
||||
const save = actions.at (-1)?.find (action => textContentOf (action.label) === '編輯内容を保存')
|
||||
await act (async () => {
|
||||
await save?.onSelect ()
|
||||
})
|
||||
@@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => {
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
|
||||
await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
|
||||
@@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => {
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
@@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => {
|
||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
@@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => {
|
||||
onSave={vi.fn ()}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
|
||||
const reset = actions.find (action => textContentOf (action.label) === '変更をリセット')
|
||||
expect (reset?.disabled).toBe (false)
|
||||
})
|
||||
|
||||
it (
|
||||
@@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => {
|
||||
|
||||
let savePromise: Promise<boolean | void> | undefined
|
||||
await act (async () => {
|
||||
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存')
|
||||
savePromise = save?.onSelect ()
|
||||
})
|
||||
|
||||
await waitFor (() => {
|
||||
|
||||
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
||||
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
|
||||
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||
thumbnailFile: undefined })
|
||||
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
|
||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||
left.url === right.url
|
||||
&& left.title === right.title
|
||||
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
|
||||
controls,
|
||||
onSave },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label } = useUITranslation ('imports')
|
||||
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
|
||||
[...new Set (values.flatMap (value => value ?? []))]
|
||||
.map (value => renderPostImportMessage (value, locale))
|
||||
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||
const [saving, setSaving] = useState (false)
|
||||
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
|
||||
return false
|
||||
|
||||
const confirmed = await controls.confirm ({
|
||||
title: '変更をリセットしますか?',
|
||||
confirmText: 'リセット',
|
||||
cancelText: '取消',
|
||||
title: label ('shared:messages.resetChanges'),
|
||||
confirmText: label ('shared:actions.reset'),
|
||||
cancelText: label ('shared:actions.cancel'),
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
setMessageRow (null)
|
||||
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||
return false
|
||||
}, [controls, resetDisabled, resetDraft])
|
||||
}, [controls, resetDisabled, resetDraft, label])
|
||||
|
||||
const save = useCallback (async (): Promise<boolean> => {
|
||||
setSaving (true)
|
||||
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
|
||||
|
||||
useEffect (() => {
|
||||
controls.setActions ([{
|
||||
label: '変更をリセット',
|
||||
label: label ('review.reset'),
|
||||
placement: 'start',
|
||||
variant: 'danger',
|
||||
disabled: resetDisabled,
|
||||
onSelect: reset },
|
||||
{
|
||||
label: '編輯内容を保存',
|
||||
label: label ('review.save'),
|
||||
disabled: saving,
|
||||
onSelect: save }])
|
||||
}, [controls, resetDisabled, reset, save, saving])
|
||||
}, [controls, resetDisabled, reset, save, saving, label])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.url,
|
||||
onChange: value => update ('url', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.url,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.url),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.url,
|
||||
displayRow.importErrors?.url) }}
|
||||
thumbnailField={
|
||||
<>
|
||||
<PostTextField
|
||||
label="サムネール"
|
||||
label={label ('shared:labels.thumbnail')}
|
||||
value={draft.thumbnailBase}
|
||||
disabled={saving}
|
||||
warnings={currentThumbnailWarnings}
|
||||
warnings={groupedMessages (currentThumbnailWarnings)}
|
||||
errors={groupedMessages (
|
||||
displayRow.validationErrors.thumbnailBase,
|
||||
displayRow.importErrors?.thumbnailBase)}
|
||||
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.title,
|
||||
onChange: value => update ('title', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.title,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.title),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.title,
|
||||
displayRow.importErrors?.title) },
|
||||
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
|
||||
value: draft.tags,
|
||||
onChange: value => update ('tags', value),
|
||||
disabled: saving,
|
||||
warnings: displayRow.fieldWarnings.tags,
|
||||
warnings: groupedMessages (displayRow.fieldWarnings.tags),
|
||||
errors: groupedMessages (
|
||||
displayRow.validationErrors.tags,
|
||||
displayRow.importErrors?.tags),
|
||||
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
|
||||
displayRow.validationErrors.videoMs,
|
||||
displayRow.importErrors?.videoMs)}/>)
|
||||
: null}/>
|
||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||
<FieldError messages={displayRow.validationErrors.base}/>
|
||||
<FieldError messages={displayRow.importErrors?.base}/>
|
||||
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
|
||||
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
|
||||
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { displayPostImportStatus,
|
||||
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import {
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
||||
?? row.baseWarnings?.[0]
|
||||
?? null
|
||||
|
||||
const summaryDate = (row: PostImportRow): string =>
|
||||
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
|
||||
originalCreatedAtString (
|
||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
||||
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
|
||||
|
||||
|
||||
const PostImportRowSummary: FC<Props> = (
|
||||
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
|
||||
showActions = true,
|
||||
showSkipToggle = false },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label } = useUITranslation ('imports')
|
||||
const localisedRowMessages = rowMessages?.map (value =>
|
||||
renderPostImportMessage (value, locale))
|
||||
const warning = summaryWarning (row)
|
||||
const displayStatus = displayPostImportStatus (row)
|
||||
const editVisible = onEdit != null
|
||||
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
checked={skipChecked}
|
||||
onChange={event => onToggleSkip?.(event.target.checked)}
|
||||
disabled={skipDisabled === true}/>
|
||||
<span>スキップ</span>
|
||||
<span>{label ('shared:actions.skip')}</span>
|
||||
</label>)
|
||||
: null
|
||||
|
||||
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
{label ('review.duration', { duration })}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
{renderPostImportMessage (warning, locale)}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
<FieldError messages={localisedRowMessages}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
{label ('shared:actions.edit')}
|
||||
</Button>)}
|
||||
{showActions && retryAllowed && (
|
||||
<Button
|
||||
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
{label ('shared:actions.retry')}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
{label ('review.duration', { duration })}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
{renderPostImportMessage (warning, locale)}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
<FieldError messages={localisedRowMessages}/>
|
||||
{skipControl}
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
{label ('shared:actions.edit')}
|
||||
</Button>)}
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
{label ('shared:actions.retry')}
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import StatusBadge from '@/components/common/StatusBadge'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
|
||||
type Props = {
|
||||
value: PostImportBadgeValue }
|
||||
|
||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||
ready: '登録可能',
|
||||
error: '登録不可',
|
||||
warning: '警告',
|
||||
skipped: 'スキップ',
|
||||
created: '登録済み',
|
||||
failed: '登録失敗' }
|
||||
|
||||
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||
ready: 'success',
|
||||
error: 'warning',
|
||||
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||
|
||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||
<StatusBadge tone={TONES[value]}>
|
||||
{LABELS[value]}
|
||||
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
|
||||
</StatusBadge>)
|
||||
|
||||
export default PostImportStatusBadge
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import { renderWithProviders as render } from '@/test/render'
|
||||
|
||||
describe ('PostImportThumbnailPreview', () => {
|
||||
beforeEach (() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
alt,
|
||||
className = 'h-16 w-16' },
|
||||
) => (
|
||||
<PostThumbnailPreview
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { i18n, translateUIMessage } from '@/lib/i18n'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
export type PostImportDisplayStatus =
|
||||
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
|
||||
|
||||
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||
|
||||
const IMPORT_MESSAGE_KEYS = [
|
||||
'validation.duplicateUrl', 'validation.processingFailed',
|
||||
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
|
||||
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
|
||||
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
|
||||
'shared:labels.thumbnailMissing']
|
||||
|
||||
|
||||
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
|
||||
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
|
||||
for (const reference of IMPORT_MESSAGE_KEYS)
|
||||
{
|
||||
const [namespace, key] =
|
||||
reference.includes (':')
|
||||
? reference.split (':')
|
||||
: ['imports', reference]
|
||||
const template: unknown = i18n.getResource ('ja', namespace, key)
|
||||
if (typeof template !== 'string')
|
||||
continue
|
||||
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
|
||||
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
|
||||
part.startsWith ('{{')
|
||||
? '(\\d+)'
|
||||
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
|
||||
const match = value.match (new RegExp (`^${pattern}$`))
|
||||
if (match == null)
|
||||
continue
|
||||
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
|
||||
const result = translateUIMessage (key, namespace, locale, values)
|
||||
return createElement (LocalisedText, { language: result.language, children: result.text })
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const hasWarnings = (row: PostImportRow): boolean =>
|
||||
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
||||
|| row.baseWarnings.length > 0
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする