Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion src/cms/cms.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ CMS.registerWidget("url-image", UrlImageControl, UrlImagePreview);

CMS.registerPreviewTemplate("index", IndexPagePreview);
CMS.registerPreviewTemplate("about", AboutPagePreview);
CMS.registerPreviewTemplate("idea", IdeaPostPreview);
CMS.registerPreviewTemplate("ideas", IdeaPostPreview);

// Decap exposes a number of lifecycle stages we can hook into and register.
CMS.registerEventListener({
Expand Down
26 changes: 0 additions & 26 deletions src/cms/preview-templates/IdeaPostPreview.js

This file was deleted.

148 changes: 148 additions & 0 deletions src/cms/preview-templates/IdeaPostPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React from "react";

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;
}

/**
* Normalize CMS form data into the shape IdeaPostTemplate expects.
* Decap gives us raw widget values which differ from resolved Gatsby data:
* - relation widgets return value_field strings, not resolved objects
* - single select widgets return a string, not an array
*/
function normalizeCmsData(
raw: Record<string, unknown>,
fieldsMetaData?: FieldsMetaData,
getAsset?: GetAsset,
): Partial<IdeaPostTemplateProps> {
const v = raw as Partial<IdeaPostTemplateProps>;

// program: single select string → array
const program = v.program;
const normalizedProgram = program
? Array.isArray(program)
? program
: [String(program)]
: 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;
const date = rawDate
? typeof rawDate === "string"
? rawDate
: new Date(rawDate as string | number).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "2-digit",
})
: undefined;

return {
...v,
authors,
date,
isPreview: true,
preliminaryFindings,
primaryContact,
program: normalizedProgram,
relatedIdeas,
resources,
};
}

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 (
<>
<div
style={{
background: "#fffbe6",
border: "1px solid #ffe58f",
borderRadius: 4,
color: "#874d00",
fontSize: 12,
margin: 8,
padding: "6px 12px",
}}
>
Previews are approximate/under development — content and styling
may differ from production, and not all functionality will be
available.
</div>
<IdeaPostTemplate
{...(normalizeCmsData(
v,
fieldsMetaData,
getAsset,
) as IdeaPostTemplateProps)}
/>
</>
);
};

export default IdeaPostPreview;
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 cotent resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tiniest: "cotent" --> "content"

* 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