feat: add copy-as-markdown and copy-as-json buttons to question card (#1305)
* feat: add copy-as-markdown and copy-as-json buttons to question card Adds two small icon buttons in the QuestionCard header so users can copy the full question payload (text, options, descriptions) to the clipboard in either Markdown or JSON form. Useful for forwarding interactive questions to an external LLM or note. - Markdown serialization preserves question headers, multi-select hints and option descriptions. - JSON serialization mirrors the on-wire QuestionRequest shape for programmatic reuse. - Toast feedback on success/failure using the shared @/components/ui wrapper and the existing copyTextToClipboard helper (clipboard API with execCommand fallback). - New i18n keys added across all 7 locales. * test: extract question serializers and cover with 15 unit tests The two serializers backing the copy-as-markdown and copy-as-json buttons on QuestionCard were defined as top-level consts inside QuestionCard.tsx. Exposing them for unit tests would trip the react-refresh/only-export-components ESLint rule because that file also exports the QuestionCard component. Move serializeQuestionAsMarkdown and serializeQuestionAsJson into a React-free sibling module, questionSerializers.ts, and re-import them from QuestionCard.tsx. Behavior is byte-identical. Add 15 unit tests via bun:test covering header fallback, multi-select hint gating, blank-description elision, ordering across multiple questions, JSON canonical shape (no transient id/sessionID), boolean normalisation, and the empty-questions edge case.
This commit is contained in:
@@ -5,12 +5,15 @@ import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import type { QuestionRequest } from '@/types/question';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: QuestionRequest;
|
||||
@@ -206,6 +209,26 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||
}
|
||||
}, [question.id, question.sessionID, rejectQuestion]);
|
||||
|
||||
const handleCopyMarkdown = React.useCallback(async () => {
|
||||
const text = serializeQuestionAsMarkdown(question);
|
||||
const result = await copyTextToClipboard(text);
|
||||
if (result.ok) {
|
||||
toast.success(t('chat.questionCard.copiedMarkdown'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('chat.questionCard.copyFailed'));
|
||||
}, [question, t]);
|
||||
|
||||
const handleCopyJson = React.useCallback(async () => {
|
||||
const text = serializeQuestionAsJson(question);
|
||||
const result = await copyTextToClipboard(text);
|
||||
if (result.ok) {
|
||||
toast.success(t('chat.questionCard.copiedJson'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('chat.questionCard.copyFailed'));
|
||||
}, [question, t]);
|
||||
|
||||
if (hasResponded || questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -229,6 +252,26 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||
{activeHeader}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={cn('flex items-center gap-0.5', activeHeader ? null : 'ml-auto')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyMarkdown}
|
||||
title={t('chat.questionCard.copyMarkdown')}
|
||||
aria-label={t('chat.questionCard.copyMarkdown')}
|
||||
className="flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
|
||||
>
|
||||
<Icon name="file-text" className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJson}
|
||||
title={t('chat.questionCard.copyJson')}
|
||||
aria-label={t('chat.questionCard.copyJson')}
|
||||
className="flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-interactive-hover/30 transition-colors"
|
||||
>
|
||||
<Icon name="code-box" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user