|
|
@@ -10,15 +10,10 @@ import {
|
|
|
type QuestionAnswer, type QuestionComposerProps,
|
|
|
} from './contract/slots.ts'
|
|
|
import type { PendingQuestion } from './contract/slots.ts'
|
|
|
+import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts'
|
|
|
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
|
|
import css from './QuestionComposer.module.css'
|
|
|
|
|
|
-interface DraftAnswer {
|
|
|
- selected: string[]
|
|
|
- custom: string
|
|
|
- skipped: boolean
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
* Displayed feedback: validation feedback is stored as a dictionary KEY and
|
|
|
* translated at render, so already-shown feedback follows a locale switch;
|
|
|
@@ -46,9 +41,9 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
|
|
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
|
|
}
|
|
|
|
|
|
-/** The free-text answer field shared by both question shapes. */
|
|
|
+/** The free-text answer field shared by both question variants. */
|
|
|
interface AnswerFieldProps {
|
|
|
- /** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
|
|
|
+ /** Visual variant: the custom row's inline column or the optionless question's framed block. */
|
|
|
variant: 'inline' | 'block'
|
|
|
/** Current draft text. */
|
|
|
value: string
|
|
|
@@ -79,7 +74,7 @@ interface AnswerFieldProps {
|
|
|
* Mirror and textarea MUST share font, line-height, padding and wrapping rules
|
|
|
* or the two heights diverge.
|
|
|
*
|
|
|
- * @param props - field shape, draft text, and the field's event handlers.
|
|
|
+ * @param props - visual variant, draft text, and the field's event handlers.
|
|
|
* @returns The mirrored auto-growing field.
|
|
|
*/
|
|
|
function AnswerField(props: AnswerFieldProps) {
|
|
|
@@ -102,14 +97,15 @@ function AnswerField(props: AnswerFieldProps) {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Composer takeover boundary; the carrier key keys local drafts, so a
|
|
|
- * same-request replay (same key, new carrier object) preserves them.
|
|
|
+ * Composer takeover router. Generic-question drafts live in this entry's
|
|
|
+ * Session-scoped Slot store, keyed by the pending carrier, so a strict Session
|
|
|
+ * entry remount restores the same request without exposing it to another one.
|
|
|
*
|
|
|
- * One takeover, two shapes: a request that declares a presentation intent this
|
|
|
- * package renders takes that shape (a plan review is one decision over one
|
|
|
+ * One takeover, two presentations: a request that declares a presentation intent this
|
|
|
+ * package renders uses that presentation (a plan review is one decision over one
|
|
|
* plan, not a question set), and every other request takes the generic flow.
|
|
|
* The routing lives here, at the one entry that owns the composer seat, so
|
|
|
- * neither shape can claim a request the other is already rendering.
|
|
|
+ * neither presentation can claim a request the other is already rendering.
|
|
|
*
|
|
|
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
|
|
* @returns The question flow, or the intent's own surface, for this request.
|
|
|
@@ -118,47 +114,74 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
|
|
const question = props.matched
|
|
|
const review = useMemo(() => planReviewOf(question.questions), [question])
|
|
|
return review === undefined
|
|
|
- ? <QuestionFlow key={question.key} pending={question} t={props.t} />
|
|
|
+ ? (
|
|
|
+ <QuestionFlow
|
|
|
+ key={question.key}
|
|
|
+ pending={question}
|
|
|
+ t={props.t}
|
|
|
+ useStore={props.useStore}
|
|
|
+ actions={props.actions}
|
|
|
+ />
|
|
|
+ )
|
|
|
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
|
|
|
}
|
|
|
|
|
|
-function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
|
|
+type QuestionFlowProps =
|
|
|
+ { pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
|
|
|
+
|
|
|
+function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
|
|
|
const questions = pending.questions
|
|
|
const markdownLabels = useMemo(() => ({
|
|
|
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
|
|
footnotes: t('markdown.footnotes'),
|
|
|
}), [t])
|
|
|
- const [index, setIndex] = useState(0)
|
|
|
- const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
|
|
- selected: [], custom: '', skipped: false,
|
|
|
- })))
|
|
|
+ const initialProgress = useMemo<QuestionDraftProgress>(() => ({
|
|
|
+ index: 0,
|
|
|
+ drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
|
|
|
+ }), [questions])
|
|
|
+ const storedProgress = useStore(state => (
|
|
|
+ state.requestKey === pending.key && state.progress.drafts.length === questions.length
|
|
|
+ ? state.progress
|
|
|
+ : undefined
|
|
|
+ ))
|
|
|
+ const { index, drafts } = storedProgress ?? initialProgress
|
|
|
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
|
|
const [error, setError] = useState<Feedback | null>(null)
|
|
|
// Collapsed to the header strip so the conversation above stays readable
|
|
|
- // while the user decides; the drafts survive because the state lives here.
|
|
|
+ // while the user decides; answer drafts live in the Session store above.
|
|
|
const [minimized, setMinimized] = useState(false)
|
|
|
// The free-form textarea autofocuses on first presentation; re-expanding a
|
|
|
// collapsed question must not steal focus from the expand toggle back into
|
|
|
// the input, so focus is granted once per question index.
|
|
|
const focusedQuestions = useRef(new Set<number>())
|
|
|
- // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
|
|
+ // Every navigation write stays in bounds and drafts mirrors questions 1:1.
|
|
|
// oxlint-disable-next-line typescript/no-non-null-assertion
|
|
|
const question = questions[index]!
|
|
|
// oxlint-disable-next-line typescript/no-non-null-assertion
|
|
|
const draft = drafts[index]!
|
|
|
const hasOptions = (question.options?.length ?? 0) > 0
|
|
|
|
|
|
+ const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
|
|
|
+ actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts })
|
|
|
+ }
|
|
|
+
|
|
|
const cancelFlow = (): void => {
|
|
|
setBusy('cancel')
|
|
|
setError(null)
|
|
|
- void pending.cancel().catch((cause: unknown) => {
|
|
|
- setBusy(null)
|
|
|
- setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
|
|
- })
|
|
|
+ void pending.cancel()
|
|
|
+ .then(() => { actions.clear(pending.key) })
|
|
|
+ .catch((cause: unknown) => {
|
|
|
+ setBusy(null)
|
|
|
+ setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
- const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
|
|
|
- setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
|
|
|
+ const updateDraft = (
|
|
|
+ update: (current: QuestionDraftAnswer) => QuestionDraftAnswer,
|
|
|
+ nextIndex = index,
|
|
|
+ ): void => {
|
|
|
+ const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item)
|
|
|
+ replaceProgress(nextIndex, nextDrafts)
|
|
|
setError(null)
|
|
|
}
|
|
|
|
|
|
@@ -171,27 +194,24 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
return { ...current, selected, skipped: false }
|
|
|
}
|
|
|
return { selected: [label], custom: '', skipped: false }
|
|
|
- })
|
|
|
- if (question.multiSelect !== true && index < questions.length - 1) {
|
|
|
- setIndex(current => current + 1)
|
|
|
- }
|
|
|
+ }, question.multiSelect !== true && index < questions.length - 1 ? index + 1 : index)
|
|
|
}
|
|
|
|
|
|
- const answered = (item: DraftAnswer): boolean =>
|
|
|
+ const answered = (item: QuestionDraftAnswer): boolean =>
|
|
|
item.selected.length > 0 || item.custom.trim() !== ''
|
|
|
|
|
|
- const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
|
|
|
+ const completed = (item: QuestionDraftAnswer): boolean => answered(item) || item.skipped
|
|
|
|
|
|
- const submitDrafts = (values: DraftAnswer[]): void => {
|
|
|
+ const submitDrafts = (values: QuestionDraftAnswer[]): void => {
|
|
|
const missing = values.findIndex(item => !completed(item))
|
|
|
if (missing >= 0) {
|
|
|
- setIndex(missing)
|
|
|
+ replaceProgress(missing, values)
|
|
|
setError({ key: 'error.incomplete' })
|
|
|
return
|
|
|
}
|
|
|
const answer: QuestionAnswer = {
|
|
|
answers: questions.map((item, itemIndex) => {
|
|
|
- const value = values[itemIndex] as DraftAnswer
|
|
|
+ const value = values[itemIndex] as QuestionDraftAnswer
|
|
|
if (value.skipped) return { id: item.id, selected: [] }
|
|
|
const custom = value.custom.trim()
|
|
|
return {
|
|
|
@@ -203,10 +223,12 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
}
|
|
|
setBusy('answer')
|
|
|
setError(null)
|
|
|
- void pending.answer(answer).catch((cause: unknown) => {
|
|
|
- setBusy(null)
|
|
|
- setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
|
|
- })
|
|
|
+ void pending.answer(answer)
|
|
|
+ .then(() => { actions.clear(pending.key) })
|
|
|
+ .catch((cause: unknown) => {
|
|
|
+ setBusy(null)
|
|
|
+ setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
const continueFlow = (): void => {
|
|
|
@@ -215,7 +237,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
return
|
|
|
}
|
|
|
if (index < questions.length - 1) {
|
|
|
- setIndex(current => current + 1)
|
|
|
+ replaceProgress(index + 1, drafts)
|
|
|
setError(null)
|
|
|
return
|
|
|
}
|
|
|
@@ -245,10 +267,9 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
|
|
? { selected: [], custom: '', skipped: true }
|
|
|
: item)
|
|
|
- setDrafts(nextDrafts)
|
|
|
+ replaceProgress(index < questions.length - 1 ? index + 1 : index, nextDrafts)
|
|
|
setError(null)
|
|
|
if (index < questions.length - 1) {
|
|
|
- setIndex(current => current + 1)
|
|
|
return
|
|
|
}
|
|
|
submitDrafts(nextDrafts)
|
|
|
@@ -382,7 +403,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
<button
|
|
|
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
|
|
disabled={index === 0 || busy !== null}
|
|
|
- onClick={() => { setIndex(index - 1); setError(null) }}
|
|
|
+ onClick={() => { replaceProgress(index - 1, drafts); setError(null) }}
|
|
|
>
|
|
|
<IconChevronLeftOutline14 />
|
|
|
</button>
|
|
|
@@ -390,7 +411,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
|
|
<button
|
|
|
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
|
|
disabled={index === questions.length - 1 || busy !== null}
|
|
|
- onClick={() => { setIndex(index + 1); setError(null) }}
|
|
|
+ onClick={() => { replaceProgress(index + 1, drafts); setError(null) }}
|
|
|
>
|
|
|
<IconChevronRightOutline14 />
|
|
|
</button>
|