diff --git a/src/cms/preview-templates/IdeaPostPreview.tsx b/src/cms/preview-templates/IdeaPostPreview.tsx index 204a7cd..f9e3ea9 100644 --- a/src/cms/preview-templates/IdeaPostPreview.tsx +++ b/src/cms/preview-templates/IdeaPostPreview.tsx @@ -4,11 +4,23 @@ import { IdeaPostTemplate, IdeaPostTemplateProps, } from "../../templates/idea-post"; +import { PreliminaryFindings } from "../../types"; import { ImmutableLike, fromImmutable } from "../utils/immutable"; +import { + FieldsMetaData, + GetAsset, + resolveAllenite, + resolveFigures, + resolveRelatedIdea, + resolveRelationList, + resolveResource, +} from "../utils/resolvers"; interface PreviewProps { entry?: ImmutableLike; value?: unknown; + fieldsMetaData?: FieldsMetaData; + getAsset?: GetAsset; } /** @@ -19,6 +31,8 @@ interface PreviewProps { */ function normalizeCmsData( raw: Record, + fieldsMetaData?: FieldsMetaData, + getAsset?: GetAsset, ): Partial { const v = raw as Partial; @@ -30,12 +44,45 @@ function normalizeCmsData( : [String(program)] : undefined; - // authors: relation gives ["name1", "name2"] → [{ name, contactId }] - const authors = v.authors - ? (v.authors as unknown as string[]).map((a) => - typeof a === "string" ? { name: a, contactId: "" } : a, - ) - : undefined; + // authors: relation gives ["name1", "name2"]; resolve each to its + // { name, contactId } node via metadata (falls back to the bare name). + const authors = resolveRelationList(v.authors, (name) => + resolveAllenite(fieldsMetaData, "authors", name), + ); + + // primaryContact: single relation → a name string; resolve the same way. + const rawPrimaryContact = v.primaryContact; + const primaryContact = + typeof rawPrimaryContact === "string" + ? resolveAllenite( + fieldsMetaData, + "primaryContact", + rawPrimaryContact, + ) + : rawPrimaryContact; + + // resources: relation gives slugs; resolve each to its flattened ResourceNode + // so MaterialsAndMethodsComponent can render (unhydrated entries dropped). + const resources = resolveRelationList(v.resources, (slug) => + resolveResource(fieldsMetaData, slug), + ); + + // related_ideas: relation gives slugs; resolve each to { title, slug }. + const relatedIdeas = resolveRelationList(raw.related_ideas, (slug) => + resolveRelatedIdea(fieldsMetaData, slug), + ); + + // preliminaryFindings.figures: uploaded images arrive as raw paths (no + // childImageSharp yet); resolve them to URLs so FigureGallery can render. + const rawFindings = raw.preliminaryFindings as + | Record + | undefined; + const preliminaryFindings = rawFindings + ? ({ + ...rawFindings, + figures: resolveFigures(rawFindings.figures, getAsset), + } as unknown as PreliminaryFindings) + : (rawFindings as PreliminaryFindings | undefined); // date: datetime widget returns a Date/object, template expects a string const rawDate = raw.date; @@ -55,11 +102,20 @@ function normalizeCmsData( authors, date, isPreview: true, + preliminaryFindings, + primaryContact, program: normalizedProgram, + relatedIdeas, + resources, }; } -const IdeaPostPreview: React.FC = ({ entry, value }) => { +const IdeaPostPreview: React.FC = ({ + entry, + fieldsMetaData, + getAsset, + value, +}) => { const raw = value ?? (entry?.get("data") as ImmutableLike | undefined); const v = fromImmutable>(raw) ?? {}; return ( @@ -80,7 +136,11 @@ const IdeaPostPreview: React.FC = ({ entry, value }) => { available. ); diff --git a/src/cms/utils/resolvers.ts b/src/cms/utils/resolvers.ts new file mode 100644 index 0000000..d0aafff --- /dev/null +++ b/src/cms/utils/resolvers.ts @@ -0,0 +1,143 @@ +import { ResourceNode } from "../../types"; +import { fromImmutable } from "./immutable"; + +/** Decap's Immutable metadata side-channel, keyed [field, collection, value]. */ +export interface FieldsMetaData { + getIn: (path: string[]) => unknown; +} + +/** + * Relation widgets (referenced via dropdowns in the CMS) need their content resolved + * via Decap's metadata side-channel. + * + * Relation widgets store only the value_field (a name or slug) in entry data; + * Decap stashes the full referenced node in `fieldsMetaData` under + * [field, collection, value]. This is populated asynchronously — on opening an + * existing entry the relation control hydrates the selected values — so this + * returns null until the lookup resolves. Each caller supplies the shape its + * template needs and decides how to handle null (fall back, or filter out). + */ +export function resolveRelation( + fieldsMetaData: FieldsMetaData | undefined, + field: string, + collection: string, + value: string, +): Record | null { + return fromImmutable>( + fieldsMetaData?.getIn([field, collection, value]), + ); +} + +/** + * Map an array of relation values (value_field strings) through a resolver, + * dropping entries that haven't hydrated yet (resolver returned null). Decap + * hands relation values as an array of value_field strings in the preview, so + * non-string entries are ignored. + */ +export function resolveRelationList( + values: unknown, + resolve: (value: string) => T | null, +): T[] | undefined { + if (!Array.isArray(values)) return undefined; + return values + .filter((v): v is string => typeof v === "string") + .map(resolve) + .filter((v): v is T => v != null); +} + +/** The subset of an allenite node the idea post template actually consumes. */ +export type ResolvedAllenite = { name: string; contactId: string | null }; + +/** allenite relation (name) → { name, contactId }; falls back to the bare name. */ +export function resolveAllenite( + fieldsMetaData: FieldsMetaData | undefined, + field: string, + name: string, +): ResolvedAllenite { + const node = resolveRelation(fieldsMetaData, field, "allenite", name); + return { + name: typeof node?.name === "string" ? node.name : name, + contactId: typeof node?.contactId === "string" ? node.contactId : null, + }; +} + +/** + * resource relation (slug) → flattened ResourceNode. The full frontmatter lives + * in metadata as `{ name, resourceDetails: {...} }`; Gatsby flattens + * resourceDetails up onto the node (see createNode in gatsby-node.js), so we + * mirror that. Returns null until hydrated; the caller filters those out. + */ +export function resolveResource( + fieldsMetaData: FieldsMetaData | undefined, + slug: string, +): ResourceNode | null { + const node = resolveRelation( + fieldsMetaData, + "resources", + "resources", + slug, + ); + if (!node) return null; + const { resourceDetails, ...rest } = node; + const details = + resourceDetails && typeof resourceDetails === "object" + ? (resourceDetails as Record) + : {}; + return { ...rest, ...details, slug } as unknown as ResourceNode; +} + +/** + * Decap's preview asset resolver. Maps a stored media path to a browser-usable + * URL, transparently handling both committed public paths (e.g. "/img/x.png") + * and freshly-uploaded, not-yet-committed files (returned as blob URLs). Returns + * an AssetProxy whose toString() is the URL. + */ +export type GetAsset = (path: string) => { toString: () => string }; + +/** + * Normalize a figure list's uploaded images for preview. + * + * In production, uploaded figures are processed by gatsby-transformer-sharp into + * `file.childImageSharp`; the preview only has the raw upload path in `file`. + * We resolve that path through getAsset and expose it as `url` — the shape + * FigureGallery renders with a plain . Figures that already have an external + * `url` render as-is and pass through untouched. + */ +export function resolveFigures( + figures: unknown, + getAsset: GetAsset | undefined, +): Record[] | undefined { + if (!Array.isArray(figures)) return undefined; + return figures.map((figure) => { + const f = (figure ?? {}) as Record; + if (!f.url && getAsset && typeof f.file === "string" && f.file) { + return { ...f, url: getAsset(f.file).toString() }; + } + return f; + }); +} + +/** The subset of a related idea the template renders (title + routing slug). */ +type ResolvedRelatedIdea = { title: string; slug: string }; + +/** + * related_ideas relation (slug) → { title, slug }. The stored value is the CMS + * slug (not the Gatsby routing path), so links can't navigate inside the preview + * iframe — the template renders these as plain text under isPreview. Falls back + * to the slug as the label until the title hydrates. + */ +export function resolveRelatedIdea( + fieldsMetaData: FieldsMetaData | undefined, + slug: string, +): ResolvedRelatedIdea { + const node = resolveRelation( + fieldsMetaData, + "related_ideas", + "ideas", + slug, + ); + return { + title: typeof node?.title === "string" ? node.title : slug, + slug, + }; +}