fix(web): contain long plan reviews
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 2px 0 0;
|
||||
margin: 0 2px 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
@@ -113,13 +113,19 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* The scrollable region of the capped card (ChatView list pattern). */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.option {
|
||||
|
||||
@@ -171,7 +171,6 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
: question.question}</span>
|
||||
{question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
||||
</h2>
|
||||
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
|
||||
</div>
|
||||
<div className={css.headerActions}>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
@@ -199,79 +198,82 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||
{(question.options ?? []).map((option, optionIndex) => {
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
event.preventDefault()
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
<span className={css.number}>{optionIndex + 1}</span>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && <span className={css.badge}>推荐</span>}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.choiceIcon}>
|
||||
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={clsx(
|
||||
css.custom,
|
||||
draft.customOpen && css.customOpen,
|
||||
!hasOptions && css.customOptionless,
|
||||
)}>
|
||||
{hasOptions && (
|
||||
<button
|
||||
type="button" className={css.customTrigger}
|
||||
disabled={busy !== null} onClick={openCustom}
|
||||
aria-expanded={draft.customOpen}
|
||||
>
|
||||
<span className={css.number}><IconEditOutline16 /></span>
|
||||
<span>其他,请填写自定义答案</span>
|
||||
</button>
|
||||
)}
|
||||
{draft.customOpen && (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customInput}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder="输入你的答案"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, customOpen: true, skipped: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
<div className={css.body} data-question-scroll>
|
||||
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
|
||||
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||
{(question.options ?? []).map((option, optionIndex) => {
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
<span className={css.number}>{optionIndex + 1}</span>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && <span className={css.badge}>推荐</span>}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.choiceIcon}>
|
||||
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={clsx(
|
||||
css.custom,
|
||||
draft.customOpen && css.customOpen,
|
||||
!hasOptions && css.customOptionless,
|
||||
)}>
|
||||
{hasOptions && (
|
||||
<button
|
||||
type="button" className={css.customTrigger}
|
||||
disabled={busy !== null} onClick={openCustom}
|
||||
aria-expanded={draft.customOpen}
|
||||
>
|
||||
<span className={css.number}><IconEditOutline16 /></span>
|
||||
<span>其他,请填写自定义答案</span>
|
||||
</button>
|
||||
)}
|
||||
{draft.customOpen && (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customInput}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder="输入你的答案"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, customOpen: true, skipped: false,
|
||||
}))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,11 @@ describe('QuestionComposer', () => {
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||
expect(screen.getByText('推荐')).toBeTruthy()
|
||||
expect(screen.getByText('工程落地型')).toBeTruthy()
|
||||
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
|
||||
const detail = screen.getByText('按当前空缺岗位的优先级选择。')
|
||||
const scrollRegion = detail.closest('[data-question-scroll]')
|
||||
expect(scrollRegion).toBeTruthy()
|
||||
expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true)
|
||||
expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false)
|
||||
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||
|
||||
Reference in New Issue
Block a user