Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions new-ui-source/src/api/adaptAuthoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import {
type ImageData,
type MediaData,
} from "@/components/storyboard/mediaMapping";
// Placeholder-title filtering is a Storyboard concern — the helpers live in
// the Storyboard folder and are used here only by the storyboard read/write
// projectors (getCourseStoryboardBlocks / saveStoryboardToCourse).
import {
isDefaultSchemaTitle,
storyboardLabel,
} from "@/components/storyboard/placeholderTitles";
import { reverseKind, isAssessmentComponentKind } from "./componentMapping";
import { parseAssessmentData, type AssessmentKind } from "@/types/storyboard";
export {
Expand Down Expand Up @@ -2052,6 +2059,11 @@ interface EngineContentNode {
properties?: Record<string, unknown>;
}

// Adapt's content model.schema falls back to placeholder titles ("New Article
// Title" etc.) whenever a node is created without an explicit title. Filtering
// those out is a Storyboard concern — see the placeholderTitles import at the
// top of this file.

// A component type installed on the instance (GET /api/componenttype).
export interface ComponentTypeOption {
component: string; // engine `_component` key, e.g. 'text'
Expand Down Expand Up @@ -2360,7 +2372,7 @@ export async function getCourseStoryboardBlocks(courseId: string): Promise<unkno
getCourseAssetIdMap(courseId),
]);

const label = (n: EngineContentNode) => n.displayTitle || n.title || "Untitled";
const label = storyboardLabel;
// Plugin fields live under `properties`; fall back to the top level for any
// legacy data written before that was fixed.
const propOf = (n: EngineContentNode, key: "_graphic" | "_media") =>
Expand Down Expand Up @@ -2457,11 +2469,30 @@ export async function getCourseStoryboardBlocks(courseId: string): Promise<unkno
}
// Assessment question components → assessment card (options + feedback).
if (sbKind && isAssessmentComponentKind(sbKind)) {
const data = parseAssessmentData(sbKind as AssessmentKind, props, stripHtml(comp.body || ""));
// ADAPT-3785 §2/§3 — Question Title source of truth + de-duplication:
// • The question title lives on the backend component's `displayTitle`
// (with `title` as a mirror). This IS the question shown to the
// learner. `body` may hold legacy text on older records.
// • The Storyboard round-trips it through `data.question` (the Body
// textarea in the assessment card) — the block-level Title input
// stays empty when displayTitle is the only source, so the same
// text never appears in two edit fields at once.
// • Only when `title` and `displayTitle` genuinely differ (an unusual
// hand-edit) do we surface the block-level title separately.
const displayTitle = ((comp.displayTitle as string) || "").trim();
const rawTitle = ((comp.title as string) || "").trim();
const cleanDisplayTitle = isDefaultSchemaTitle(displayTitle) ? "" : displayTitle;
const cleanTitle = isDefaultSchemaTitle(rawTitle) ? "" : rawTitle;
const questionSeed = cleanDisplayTitle || cleanTitle || stripHtml(comp.body || "");
// Block-title input stays empty unless the AT stored a distinct `title`
// (independent of displayTitle) — avoids duplicating displayTitle into
// the block-title input on reload.
const blockTitleProp = cleanTitle && cleanTitle !== questionSeed ? cleanTitle : "";
const data = parseAssessmentData(sbKind as AssessmentKind, props, questionSeed);
out.push({
id: comp._id,
type: "sbAssessment",
props: { kind: sbKind, title: label(comp), adaptComponent: kindOf, data: JSON.stringify(data) },
props: { kind: sbKind, title: blockTitleProp, adaptComponent: kindOf, data: JSON.stringify(data) },
});
return;
}
Expand Down Expand Up @@ -2551,14 +2582,24 @@ export async function getCourseStoryboardBlocks(courseId: string): Promise<unkno
return;
}
// Unknown / text → H4 heading + body paragraph (text write-back contract).
out.push({ id: comp._id, type: "heading", props: { level: 4 }, content: label(comp) });
// Suppress the H4 entirely when the component has no authored title —
// otherwise the storyboard/export show an anonymous heading line above
// the body paragraph, which reads as an "empty title" placeholder.
const compTitle = label(comp);
if (compTitle) out.push({ id: comp._id, type: "heading", props: { level: 4 }, content: compTitle });
const bodyText = stripHtml(comp.body || "");
if (bodyText) out.push({ id: `${comp._id}${BODY_SUFFIX}`, type: "paragraph", content: bodyText });
};
const emitTopic = (page: EngineContentNode) => {
out.push({ id: page._id, type: "heading", props: { level: 1 }, content: label(page) });
// A page/article/block with no authored title (schema default like
// "New Menu/Page Title") is projected without its header — see the
// storyboardLabel + DEFAULT_SCHEMA_TITLES filter. Emitting empty headings
// clutters the document with blank lines and pollutes the Word export.
const topicTitle = label(page);
if (topicTitle) out.push({ id: page._id, type: "heading", props: { level: 1 }, content: topicTitle });
for (const article of childrenOf(articles, page._id)) {
out.push({ id: article._id, type: "heading", props: { level: 2 }, content: label(article) });
const articleTitle = label(article);
if (articleTitle) out.push({ id: article._id, type: "heading", props: { level: 2 }, content: articleTitle });
// The generation engine caps each Adapt block at 2 components — extra
// components are placed in continuation blocks that carry the SAME H3
// title. When we round-trip the course, those continuation blocks would
Expand All @@ -2568,7 +2609,8 @@ export async function getCourseStoryboardBlocks(courseId: string): Promise<unkno
let prevTitle: string | null = null;
for (const blk of childrenOf(blocks, article._id)) {
const title = label(blk);
if (title !== prevTitle) {
// Same suppression rule as pages/articles above.
if (title && title !== prevTitle) {
out.push({ id: blk._id, type: "heading", props: { level: 3 }, content: title });
prevTitle = title;
}
Expand Down Expand Up @@ -2601,7 +2643,7 @@ export async function saveStoryboardToCourse(
getContentByCourse("component", courseId),
]);

const label = (n: EngineContentNode) => n.displayTitle || n.title || "Untitled";
const label = storyboardLabel;
const index = new Map<
string,
{ level: StructureLevel; title: string; body?: string; component?: string; parentId?: string }
Expand Down
40 changes: 31 additions & 9 deletions new-ui-source/src/api/storyboardGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,12 +403,29 @@ function parseDocToTree(doc: unknown[], resolveExisting: (id: string) => string
} else if (type === "sbAssessment") {
const kind = ((raw.props && raw.props.kind) || "mcq") as AssessmentKind;
const data = safeParseJson<AssessmentData>(raw.props && raw.props.data, { question: "" });
// Learner-facing Title/Body mapping (PR review — Title is primary):
// • The block-level Title input is the primary question header — it
// becomes the component's `title` + `displayTitle` in the Adapt
// content model.
// • When the Title is empty, the question body stands in as the title.
// • Final fallback "Question" only when both are empty (schema needs
// a non-empty title for the component to save).
//
// Body-vs-Title de-duplication:
// The question body is written into `body` only when it differs from
// the resolved title, so authors who provide a distinct Title and Body
// get both, while a body that already became the title is never
// rendered twice (once as title, once as description).
const blockTitle = ((raw.props && (raw.props.title as string)) || "").trim();
const questionText = (data.question || "").trim();
const resolvedTitle = blockTitle || questionText || "Question";
const bodyText = questionText && questionText !== resolvedTitle ? questionText : "";
comp = {
sourceBlockId: id,
existingId,
componentKey: kind,
title: ((raw.props && raw.props.title) || data.question || "").trim() || "Question",
body: data.question || "",
title: resolvedTitle,
body: bodyText,
assessmentPatch: buildAssessmentFields(kind, data),
};
// GMCQ: record per-option DAM-asset ids so we can create the courseasset
Expand Down Expand Up @@ -550,9 +567,11 @@ export async function planStoryboardGeneration(
if (raw.type === "sbAssessment") {
const kind = (raw.props && raw.props.kind) || "";
const data = safeParseJson<AssessmentData>(raw.props && raw.props.data, { question: "" });
const blockTitle = ((raw.props && (raw.props.title as string)) || "").trim();
if (isAssessmentKind(kind)) {
const problems = validateAssessment(kind, data);
if (problems.length) warnings.push(`Assessment "${data.question || kind}": ${problems[0]}`);
// Block Title is a valid question source (ADAPT-3785 §2 — feeds `displayTitle`).
const problems = validateAssessment(kind, data, blockTitle);
if (problems.length) warnings.push(`Assessment "${data.question || blockTitle || kind}": ${problems[0]}`);
}
}
}
Expand Down Expand Up @@ -678,12 +697,15 @@ export async function generateStoryboardCourse(
gSort += 1;

let cSort = 1;
for (const c of g.components) {
for (let ci = 0; ci < g.components.length; ci += 1) {
const c = g.components[ci];
const bodyHtml = bodyHtmlOf(c);
// Default component alignment is Left. New components added to the
// Storyboard are always generated (and saved) with left alignment so
// the Storyboard sequence and layout match the generated course.
const layout: "left" = "left";
// A Content Group (block) holding a single component should fill
// the full width (matches the Course Preview / real Adapt layout —
// there's nothing to sit beside it). Two components split left/right,
// as before. `enforceMaxComponentsPerBlock` guarantees ≤2 per group.
const layout: "full" | "left" | "right" =
g.components.length <= 1 ? "full" : ci === 0 ? "left" : "right";
// Resolve the storyboard kind → installed Adapt component (source of
// truth). null = unsupported (NO text fallback).
const resolvedType = getType(c.componentKey);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// "Samaritan Assistance" AI popover for the storyboard (ADAPT-3760).
// "Samaritan Assistance" AI popover for the storyboard.
//
// Functional parity with the legacy CKEditor "Samaritan Assistance" tool
// (frontend/src/modules/scaffold/backboneFormsOverrides.js): four fixed actions
Expand Down
2 changes: 1 addition & 1 deletion new-ui-source/src/components/storyboard/CommentPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Block-anchored comment popover for the storyboard (ADAPT-3760).
// Block-anchored comment popover for the storyboard.
//
// Opened from Add Content → Comment. Reuses the existing storyboardcomment
// backend via useStoryboardReview (add / resolve / delete / reply) — comments
Expand Down
47 changes: 19 additions & 28 deletions new-ui-source/src/components/storyboard/DocumentToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
// Center toolbar above the canvas (spec AC2/AC3/AC7, Figma-aligned ADAPT-3842).
// Row 1: STORYBOARD DOCUMENT · Add Heading · Add Content · Add Instruction ·
// (Refresh from course)
// Row 2: Enrich with AI (Samaritan-accented outline button)
// Single row: STORYBOARD DOCUMENT · Add Heading · Add Content ·
// (Refresh from course · Enrich with AI, right-aligned)

import { FileText, Info, RefreshCw, Sparkles } from 'lucide-react';
import { FileText, RefreshCw, Sparkles } from 'lucide-react';
import type { StoryboardInsertKind } from '@/types/storyboard';
import AddContentMenu from './AddContentMenu';
import HeadingMenu from './HeadingMenu';
Expand Down Expand Up @@ -43,33 +41,26 @@ export default function DocumentToolbar({
</div>
<HeadingMenu onSelect={onInsertHeading} />
<AddContentMenu onInsert={onInsert} />
<button
type="button"
onClick={() => onInsert('instruction')}
className="sb-toolbar-btn"
>
<Info className="h-3.5 w-3.5" /> Add Instruction
</button>
{onRefresh && (

<div className="ml-auto flex items-center gap-2">
{onRefresh && (
<button
type="button"
onClick={onRefresh}
title="Reload the storyboard from the latest course content"
className="sb-toolbar-btn"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh from course
</button>
)}
<button
type="button"
onClick={onRefresh}
title="Reload the storyboard from the latest course content"
className="sb-toolbar-btn ml-auto"
onClick={onEnrichAI}
className="sb-toolbar-btn sb-toolbar-btn-samaritan"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh from course
<Sparkles className="h-3.5 w-3.5" /> Enrich with AI
</button>
)}
</div>

<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onEnrichAI}
className="sb-toolbar-btn sb-toolbar-btn-samaritan"
>
<Sparkles className="h-3.5 w-3.5" /> Enrich with AI
</button>
</div>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion new-ui-source/src/components/storyboard/GenerateDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Generate-course dialog (ADAPT-3760, Phase 4 / AC11, Figma-aligned ADAPT-3842).

// Shows a pre-generation validation report + plan summary, then the result.

import { Loader2, X, AlertTriangle, CheckCircle2, ArrowRight } from 'lucide-react';
Expand Down
1 change: 0 additions & 1 deletion new-ui-source/src/components/storyboard/HeadingMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// "Add Heading" dropdown (spec AC4, Figma-aligned ADAPT-3842).
//
// Matches the Figma "Course Creation Center" HeadingDropdown: each option is
// a two-line row with a bold H1/H2/H3 badge (`.sb-heading-chip`) and the
Expand Down
51 changes: 2 additions & 49 deletions new-ui-source/src/components/storyboard/ReviewCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,8 @@ export default function ReviewCenter({

const tabs: { id: Tab; label: string }[] = [
{ id: 'open', label: `Open (${review.openCount})` },
{ id: 'resolved', label: `Resolved (${review.resolvedCount})` },
{ id: 'approvals', label: 'Approvals' },
{ id: 'activity', label: 'Activity' },
{ id: 'resolved', label: `Resolved (${review.resolvedCount})` }

];

const addTopLevel = () => {
Expand Down Expand Up @@ -366,52 +365,6 @@ export default function ReviewCenter({
/>
))
))}

{tab === 'approvals' && (
<div
className="p-4 text-center"
style={{
fontSize: 13,
color: 'var(--life-color-text-subtle)',
border: '1px dashed var(--life-color-border-subtle)',
borderRadius: 'var(--radius)',
}}
>
Current status:{' '}
<span style={{ fontWeight: 600, color: 'var(--life-color-text-default)' }}>
{status.replace('_', ' ')}
</span>
.<br />
Use the status pill in the top bar to move Draft → In Review → Approved.
</div>
)}

{tab === 'activity' &&
(review.audit.length === 0 ? (
<p
className="text-center"
style={{ fontSize: 13, color: 'var(--life-color-text-subtle)' }}
>
No activity yet.
</p>
) : (
review.audit.map((a: StoryboardAuditEvent) => (
<div key={a._id} className="sb-card" style={{ fontSize: 13 }}>
<span style={{ fontWeight: 600, color: 'var(--life-color-text-default)' }}>
{a.event.replace('_', ' ')}
</span>
{a.fromStatus && a.toStatus && (
<span style={{ color: 'var(--life-color-text-subtle)' }}>
{' '}
— {a.fromStatus.replace('_', ' ')} → {a.toStatus.replace('_', ' ')}
</span>
)}
<p style={{ marginTop: 2, fontSize: 11, color: 'var(--life-color-text-subtle)' }}>
{timeAgo(a.createdAt)}
</p>
</div>
))
))}
</div>
</aside>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Storyboard top bar (spec AC8/AC10/AC11), Figma-aligned (ADAPT-3842).
// Storyboard top bar (spec AC8/AC10/AC11), Figma-aligned.
// Back · Draft status · Save · Import · Export ▾ · Share for Review ·
// Generate Course →
// Backend-dependent actions (Import/Export/Share/Generate) are stubbed with a
Expand Down
Loading