Files
openchamber/packages/ui/src/components/chat/questionSerializers.ts
T
Roberto Bertó fbffce4fdf 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.
2026-05-18 17:54:19 +03:00

73 lines
2.4 KiB
TypeScript

import type { QuestionRequest } from '@/types/question';
/**
* Pure serializers for QuestionRequest payloads.
*
* Extracted from QuestionCard.tsx so they can be unit-tested without
* pulling the component tree. Living in QuestionCard.tsx triggered the
* `react-refresh/only-export-components` rule when exposed for tests;
* the React-free home here avoids that constraint and keeps the
* QuestionCard import surface focused on rendering.
*/
/**
* Render a QuestionRequest as Markdown the user can paste into another
* tool (chat with a companion model, issue tracker, doc, etc.).
*
* Layout per question:
* ## <header or fallback>
*
* <question body>
*
* _Select all that apply._ (only when q.multiple)
*
* - **<label>** — <description> (description elided when blank)
*/
export function serializeQuestionAsMarkdown(question: QuestionRequest): string {
const lines: string[] = [];
const questions = question.questions ?? [];
questions.forEach((q, index) => {
const header = q.header?.trim();
const title = header && header.length > 0 ? header : `Question ${index + 1}`;
lines.push(`## ${title}`);
lines.push('');
lines.push(q.question);
lines.push('');
if (q.multiple) {
lines.push('_Select all that apply._');
lines.push('');
}
q.options.forEach((option) => {
const label = option.label;
const description = option.description?.trim();
lines.push(description ? `- **${label}** — ${description}` : `- **${label}**`);
});
lines.push('');
});
return lines.join('\n').trimEnd();
}
/**
* Render a QuestionRequest as a stable JSON envelope.
*
* Mirrors the on-wire `QuestionRequest` shape minus the transient `id`
* and `sessionID` (which are local routing concerns, not part of the
* question content). `header` and `description` are normalised to
* `null` when absent so consumers do not have to distinguish `undefined`
* from `missing key`.
*/
export function serializeQuestionAsJson(question: QuestionRequest): string {
const payload = {
questions: (question.questions ?? []).map((q) => ({
header: q.header ?? null,
question: q.question,
multiple: Boolean(q.multiple),
options: q.options.map((option) => ({
label: option.label,
description: option.description ?? null,
})),
})),
};
return JSON.stringify(payload, null, 2);
}