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
76 changes: 68 additions & 8 deletions src/cms/preview-templates/IdeaPostPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -19,6 +31,8 @@ interface PreviewProps {
*/
function normalizeCmsData(
raw: Record<string, unknown>,
fieldsMetaData?: FieldsMetaData,
getAsset?: GetAsset,
): Partial<IdeaPostTemplateProps> {
const v = raw as Partial<IdeaPostTemplateProps>;

Expand All @@ -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<string, unknown>
| 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;
Expand All @@ -55,11 +102,20 @@ function normalizeCmsData(
authors,
date,
isPreview: true,
preliminaryFindings,
primaryContact,
program: normalizedProgram,
relatedIdeas,
resources,
};
}

const IdeaPostPreview: React.FC<PreviewProps> = ({ entry, value }) => {
const IdeaPostPreview: React.FC<PreviewProps> = ({
entry,
fieldsMetaData,
getAsset,
value,
}) => {
const raw = value ?? (entry?.get("data") as ImmutableLike | undefined);
const v = fromImmutable<Record<string, unknown>>(raw) ?? {};
return (
Expand All @@ -80,7 +136,11 @@ const IdeaPostPreview: React.FC<PreviewProps> = ({ entry, value }) => {
available.
</div>
<IdeaPostTemplate
{...(normalizeCmsData(v) as IdeaPostTemplateProps)}
{...(normalizeCmsData(
v,
fieldsMetaData,
getAsset,
) as IdeaPostTemplateProps)}
/>
</>
);
Expand Down
143 changes: 143 additions & 0 deletions src/cms/utils/resolvers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
return fromImmutable<Record<string, unknown>>(
fieldsMetaData?.getIn([field, collection, value]),
);
}

Comment on lines +1 to +30

@interim17 interim17 Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is primarily agent written.

We are taking string identifiers like "allenite" for known relation widgets and getting the data out of Decap's fieldsMetaData with the necessary filtering and normalizing.

/**
* 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<T>(
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<string, unknown>)
: {};
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 <img>. Figures that already have an external
* `url` render as-is and pass through untouched.
*/
export function resolveFigures(
figures: unknown,
getAsset: GetAsset | undefined,
): Record<string, unknown>[] | undefined {
if (!Array.isArray(figures)) return undefined;
return figures.map((figure) => {
const f = (figure ?? {}) as Record<string, unknown>;
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,
};
}
Loading