From 0ac7332458a9e1a4d7621f4bbacea859b6e9ba45 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 15 Jul 2026 22:41:06 +0900 Subject: [PATCH] #399 --- .../components/dialogues/DialogueProvider.tsx | 291 +++++++++++------- .../posts/import/PostImportRowForm.tsx | 97 +++--- frontend/src/lib/dialogues/useDialogue.ts | 69 ++++- .../src/pages/posts/PostImportReviewPage.tsx | 106 ++----- 4 files changed, 331 insertions(+), 232 deletions(-) diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index fb77ecf..4357b19 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, @@ -7,29 +7,16 @@ import { Dialog, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DialogueContext, useDialogue } from '@/lib/dialogues/useDialogue' import type { FC, ReactNode } from 'react' - -type DialogueVariant = 'default' | 'danger' - -type ConfirmOptions = { title: string - description?: ReactNode - confirmText?: string - cancelText?: string - variant?: DialogueVariant } - -type AlertOptions = { title: string - description?: ReactNode - okText?: string } - -type Choice = { value: T - label: string - variant?: DialogueVariant } - -type ChoiceOptions = { title: string - description?: ReactNode - choices: Choice[] - cancelText?: string } +import type { AlertOptions, + ChoiceOptions, + ConfirmOptions, + DialogueAPI, + DialogueFormAction, + DialogueFormControls, + DialogueFormOptions } from '@/lib/dialogues/useDialogue' type DialogueRequest = | { id: number @@ -44,13 +31,10 @@ type DialogueRequest = kind: 'choice' options: ChoiceOptions resolve: (value: string | null) => void } - -type DialogueAPI = - { confirm: (options: ConfirmOptions) => Promise - alert: (options: AlertOptions) => Promise - choice: (options: ChoiceOptions) => Promise } - -const DialogueContext = createContext (null) + | { id: number + kind: 'form' + options: DialogueFormOptions + resolve: () => void } let nextDialogueId = 1 @@ -58,21 +42,24 @@ type Props = { children: ReactNode } const DialogueProvider: FC = ({ children }) => { - const [queue, setQueue] = useState ([]) + const [stack, setStack] = useState ([]) + const [pendingIds, setPendingIds] = useState ([]) + const [formActions, setFormActions] = useState> ({ }) + const formControls = useRef> ({ }) const push = useCallback ((request: Omit) => { const id = nextDialogueId ++nextDialogueId - setQueue (q => [...q, { ...request, id } as DialogueRequest]) + setStack (current => [...current, { ...request, id } as DialogueRequest]) }, []) - const closeActive = useCallback ((result?: unknown) => { - setQueue (q => { - const [active, ...rest] = q + const closeRequest = useCallback ((id: number, result?: unknown) => { + setStack (current => { + const active = current.find (request => request.id === id) - if (!(active)) - return rest + if (active == null) + return current switch (active.kind) { @@ -87,12 +74,28 @@ const DialogueProvider: FC = ({ children }) => { case 'choice': active.resolve ((result ?? null) as string | null) break + + case 'form': + active.resolve () + break } - return rest + return current.filter (request => request.id !== id) }) + setPendingIds (current => current.filter (_1 => _1 !== id)) + setFormActions (current => { + const { [id]: _, ...rest } = current + return rest + }) + delete formControls.current[id] }, []) + const setRequestActions = useCallback ( + (id: number, actions: DialogueFormAction[]) => { + setFormActions (current => ({ ...current, [id]: actions })) + }, + []) + const api = useMemo (() => ({ confirm: options => new Promise (resolve => { push ({ kind: 'confirm', options, resolve }) @@ -103,86 +106,164 @@ const DialogueProvider: FC = ({ children }) => { choice: options => new Promise (resolve => { push ({ kind: 'choice', options: options as ChoiceOptions, - resolve: resolve as (value: string | null) => void })}) }), [push]) + resolve: resolve as (value: string | null) => void }) + }), + form: options => new Promise (resolve => { + push ({ kind: 'form', options, resolve }) + }) }), [push]) - const active = queue[0] + const handleFormAction = useCallback ( + async (id: number, action: DialogueFormAction) => { + if (pendingIds.includes (id)) + return + + setPendingIds (current => [...current, id]) + try + { + const shouldClose = await action.onSelect () + if (shouldClose !== false) + closeRequest (id) + } + finally + { + setPendingIds (current => current.filter (_1 => _1 !== id)) + } + }, + [closeRequest, pendingIds]) return ( {children} - { - if (!(open)) - closeActive (active?.kind !== 'confirm' && null) - }}> - {active && ( - - - {active.options.title} + {stack.map ((request, index) => { + const isTop = index === stack.length - 1 + const pending = pendingIds.includes (request.id) + const controls = + request.kind === 'form' + ? (formControls.current[request.id] ??= { + close: () => closeRequest (request.id), + setActions: actions => setRequestActions (request.id, actions) }) + : null + const requestActions = formActions[request.id] ?? [] - {active.options.description && ( - -
{active.options.description}
-
)} -
+ return ( + { + if (!(open) && isTop && !(pending)) + closeRequest (request.id, request.kind !== 'confirm' && null) + }}> + { + if (pending) + event.preventDefault () + }} + onPointerDownOutside={event => { + if (pending) + event.preventDefault () + }}> + {request.kind === 'form' + ? ( + <> + + {request.options.title} - - {active.kind === 'confirm' && ( - <> - + {request.options.description && ( + +
{request.options.description}
+
)} +
- - )} +
+ {controls && request.options.body (controls)} +
- {active.kind === 'alert' && ( - )} + + - {active.kind === 'choice' && ( - <> - + {requestActions.map (action => ( + ))} + + ) + : ( + <> + + {request.options.title} - {active.options.choices.map (choice => ( - ))} - )} - -
)} -
+ {request.options.description && ( + +
{request.options.description}
+
)} + + + + {request.kind === 'confirm' && ( + <> + + + + )} + + {request.kind === 'alert' && ( + )} + + {request.kind === 'choice' && ( + <> + + + {request.options.choices.map (choice => ( + ))} + )} + + )} +
+
) + })}
) } - - -export const useDialogue = () => { - const dialogue = useContext (DialogueContext) - - if (!(dialogue)) - throw new Error ('useDialogue must be used inside DialogueProvider') - - return dialogue -} - +export { useDialogue } export default DialogueProvider diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 0c23883..bb7a71c 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -1,17 +1,17 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, 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 ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' -import { Button } from '@/components/ui/button' import { useDialogue } from '@/lib/dialogues/useDialogue' import { inputClass } from '@/lib/utils' -import type { Dispatch, FC, SetStateAction } from 'react' +import type { FC } from 'react' import type { PostImportRow } from '@/lib/postImportSession' +import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' type Draft = { url: string @@ -24,11 +24,12 @@ type Draft = { parentPostIds: string } type Props = { - row: PostImportRow - messageRow?: PostImportRow | null - saving: boolean - onDraftChange: Dispatch> - onResetRequestedChange: Dispatch> } + row: PostImportRow + controls: DialogueFormControls + onSave: (args: { draft: Draft + resetRequested: boolean }) => Promise<{ + saved: boolean + row: PostImportRow | null }> } const buildDraft = (row: PostImportRow): Draft => ({ url: row.url, @@ -66,22 +67,21 @@ const sameDraft = (left: Draft, right: Draft): boolean => const PostImportRowForm: FC = ( { row, - messageRow, - saving, - onDraftChange, - onResetRequestedChange }, + controls, + onSave }, ) => { const dialogue = useDialogue () const [draft, setDraft] = useState (() => buildDraft (row)) + const [messageRow, setMessageRow] = useState (null) + const [saving, setSaving] = useState (false) const [resetRequested, setResetRequested] = useState (false) useEffect (() => { const nextDraft = buildDraft (row) setDraft (nextDraft) + setMessageRow (null) setResetRequested (false) - onDraftChange (nextDraft) - onResetRequestedChange (false) - }, [onDraftChange, onResetRequestedChange, row]) + }, [row]) const displayRow = messageRow ?? row const resetDraft = buildResetDraft (row) @@ -91,16 +91,14 @@ const PostImportRowForm: FC = ( key: Key, value: Draft[Key], ) => { - setDraft (current => { - const next = { ...current, [key]: value } - onDraftChange (next) - return next - }) + if (messageRow != null) + setMessageRow (null) + setDraft (current => ({ ...current, [key]: value })) } - const reset = async () => { + const reset = useCallback (async (): Promise => { if (resetDisabled) - return + return false const confirmed = await dialogue.confirm ({ title: '変更をリセットしますか?', @@ -109,22 +107,48 @@ const PostImportRowForm: FC = ( cancelText: '取消', variant: 'danger' }) if (!(confirmed)) - return + return false setDraft (resetDraft) setResetRequested (true) - onDraftChange (resetDraft) - onResetRequestedChange (true) - } + setMessageRow (null) + return false + }, [dialogue, resetDisabled, resetDraft]) + + const save = useCallback (async (): Promise => { + setSaving (true) + try + { + const result = await onSave ({ draft, resetRequested }) + if (result.saved) + return true + + if (result.row != null) + setMessageRow (result.row) + return false + } + finally + { + setSaving (false) + } + }, [draft, onSave, resetRequested]) + + useEffect (() => { + controls.setActions ([{ + label: '変更をリセット', + variant: 'danger', + disabled: resetDisabled, + onSelect: reset }, + { + label: '編輯内容を保存', + disabled: saving, + onSelect: save }]) + }, [controls, resetDisabled, reset, save, saving]) return ( <> -
+
-

- 投稿 {row.sourceRow} の内容を確認し、必要な項目を編輯してください. -

-
= ( - -
- -
@@ -259,4 +273,3 @@ const PostImportAreaField = ( export default PostImportRowForm export { buildDraft } export type { Draft as PostImportRowDraft } -export type { Draft as PostImportRowDraft } diff --git a/frontend/src/lib/dialogues/useDialogue.ts b/frontend/src/lib/dialogues/useDialogue.ts index 0de1567..01b0db3 100644 --- a/frontend/src/lib/dialogues/useDialogue.ts +++ b/frontend/src/lib/dialogues/useDialogue.ts @@ -1,5 +1,70 @@ -import { useDialogue as useDialogueFromProvider } from '@/components/dialogues/DialogueProvider' +import { createContext, useContext } from 'react' -export const useDialogue = () => useDialogueFromProvider () +import type { ReactNode } from 'react' +type DialogueVariant = 'default' | 'danger' + +type ConfirmOptions = { title: string + description?: ReactNode + confirmText?: string + cancelText?: string + variant?: DialogueVariant } + +type AlertOptions = { title: string + description?: ReactNode + okText?: string } + +type Choice = { value: T + label: string + variant?: DialogueVariant } + +type ChoiceOptions = { title: string + description?: ReactNode + choices: Choice[] + cancelText?: string } + +type DialogueFormAction = { + label: string + variant?: DialogueVariant + disabled?: boolean + onSelect: () => Promise | boolean | void } + +type DialogueFormControls = { + close: () => void + setActions: (actions: DialogueFormAction[]) => void } + +type DialogueFormOptions = { title: string + description?: ReactNode + body: (controls: DialogueFormControls) => ReactNode + cancelText?: string + size?: 'default' | 'large' } + +type DialogueAPI = + { confirm: (options: ConfirmOptions) => Promise + alert: (options: AlertOptions) => Promise + choice: (options: ChoiceOptions) => Promise + form: (options: DialogueFormOptions) => Promise } + +const DialogueContext = createContext (null) + +const useDialogue = () => { + const dialogue = useContext (DialogueContext) + + if (dialogue == null) + throw new Error ('useDialogue must be used inside DialogueProvider') + + return dialogue +} + +export { DialogueContext, useDialogue } export default useDialogue +export type { + AlertOptions, + Choice, + ChoiceOptions, + ConfirmOptions, + DialogueAPI, + DialogueFormAction, + DialogueFormControls, + DialogueFormOptions, + DialogueVariant } diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 85af48c..8deac39 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import PostImportRowForm, { buildDraft } from '@/components/posts/import/PostImportRowForm' +import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' @@ -43,12 +43,6 @@ const PostImportReviewPage: FC = ({ user }) => { const [session, setSession] = useState (null) const [loading, setLoading] = useState (false) const [missing, setMissing] = useState (false) - const [savingRow, setSavingRow] = useState (null) - const [dialogueMessageRow, setDialogueMessageRow] = useState (null) - const [dialogueDraft, setDialogueDraft] = useState (null) - const [dialogueResetRequested, setDialogueResetRequested] = useState (false) - const dialogueDraftRef = useRef (null) - const dialogueResetRequestedRef = useRef (false) useEffect (() => { if (sessionId == null) @@ -68,14 +62,6 @@ const PostImportReviewPage: FC = ({ user }) => { toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) - useEffect (() => { - dialogueDraftRef.current = dialogueDraft - }, [dialogueDraft]) - - useEffect (() => { - dialogueResetRequestedRef.current = dialogueResetRequested - }, [dialogueResetRequested]) - const rows = session?.rows ?? [] const editingSourceRow = Number (searchParams.get ('edit') ?? '') const editingRow = @@ -107,21 +93,6 @@ const PostImportReviewPage: FC = ({ user }) => { element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) }, [editingRow, session?.repairMode]) - useEffect (() => { - if (editingRow == null) - { - setDialogueMessageRow (null) - setDialogueDraft (null) - setDialogueResetRequested (false) - return - } - if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow) - setDialogueMessageRow (null) - setDialogueDraft (current => - current != null ? current : buildDraft (editingRow)) - setDialogueResetRequested (false) - }, [editingRow, dialogueMessageRow]) - const updateSessionRows = (nextRows: PostImportRow[]) => setSession (current => current != null ? { ...current, rows: nextRows } : current) @@ -155,11 +126,10 @@ const PostImportReviewPage: FC = ({ user }) => { const urlChanged = draft.url !== baseRow.url const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) - setSavingRow (editingRow.sourceRow) const nextRows = session.rows.map (row => - row.sourceRow === editingRow.sourceRow - ? nextRow - : row) + row.sourceRow === editingRow.sourceRow + ? nextRow + : row) try { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { @@ -176,12 +146,8 @@ const PostImportReviewPage: FC = ({ user }) => { const validatedRows = initialisePreviewRows (validated.rows) const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow) if (target != null && Object.keys (target.validationErrors).length > 0) - { - setDialogueMessageRow (target) - return { saved: false, row: target } - } + return { saved: false, row: target } updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) - setDialogueMessageRow (null) setSearchParams ({ }) return { saved: true, row: null } } @@ -192,48 +158,25 @@ const PostImportReviewPage: FC = ({ user }) => { } finally { - setSavingRow (null) } } const openEditingDialogue = async (row: PostImportRow) => { - let currentRow = row - - while (true) - { - const initialDraft = buildDraft (currentRow) - setDialogueDraft (current => current != null ? current : initialDraft) - setDialogueResetRequested (false) - - const action = await dialogue.choice ({ - title: '投稿を編輯', - description: , - cancelText: '取消', - choices: [{ value: 'save', label: '編輯内容を保存' }] as const }) - - if (action !== 'save') - { - setSearchParams ({ }) - return - } - - const draft = dialogueDraftRef.current ?? initialDraft - const result = await saveDraft ({ - draft, - resetRequested: dialogueResetRequestedRef.current, - resetSnapshot: currentRow.resetSnapshot }) - if (result.saved) - return - - currentRow = result.row ?? currentRow - } + await dialogue.form ({ + title: '投稿を編輯', + description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`, + cancelText: '取消', + size: 'large', + body: controls => ( + + saveDraft ({ + draft, + resetRequested, + resetSnapshot: row.resetSnapshot })}/>) }) + setSearchParams ({ }) } useEffect (() => { @@ -267,7 +210,6 @@ const PostImportReviewPage: FC = ({ user }) => { if (firstInvalid != null) { setSession ({ ...session, rows: mergedRows }) - setDialogueMessageRow (firstInvalid) setSearchParams ({ edit: String (firstInvalid.sourceRow) }) return } @@ -308,7 +250,6 @@ const PostImportReviewPage: FC = ({ user }) => { rows: nextRows, repairMode: 'failed' as const } setSession (repairSession) - setDialogueMessageRow (firstRecoverable) const saved = savePostImportSession (sessionId, repairSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (saved) @@ -358,7 +299,7 @@ const PostImportReviewPage: FC = ({ user }) => {
- 投稿情報の確認・編輯 + 投稿情報の確認
{reviewRows.map (row => ( @@ -380,7 +321,6 @@ const PostImportReviewPage: FC = ({ user }) => { skipPlannedCount={counts.skipPlanned} onBack={() => navigate ('/posts/import')} onSubmit={submit}/> - ) } @@ -415,7 +355,7 @@ const PostImportFooter = ( type="button" onClick={onSubmit} disabled={loading || processableCount === 0}> - 取込を実行 + 取込実行