From 37fd2cf8c09d33910f244dab24bc3c7e08d7534b Mon Sep 17 00:00:00 2001 From: Joe Heffernan Date: Thu, 19 Feb 2026 14:23:47 -0800 Subject: [PATCH 1/8] register custom widget for variable type resources --- gatsby-node.js | 1 + src/cms/cms.js | 15 + .../VariableResourceUnionControl.tsx | 21 ++ .../VariableResourceWidget/constants.ts | 105 +++++++ .../copyResourceNameHandler.ts | 37 +++ .../VariableTypeWidgetControl.tsx | 274 ++++++++++++++++++ src/cms/widgets/VariableTypeWidget/types.ts | 16 + static/admin/config.yml | 59 +++- 8 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 src/cms/widgets/VariableResourceWidget/VariableResourceUnionControl.tsx create mode 100644 src/cms/widgets/VariableResourceWidget/constants.ts create mode 100644 src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts create mode 100644 src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx create mode 100644 src/cms/widgets/VariableTypeWidget/types.ts diff --git a/gatsby-node.js b/gatsby-node.js index 10cc70f..91995d9 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -20,6 +20,7 @@ const DATA_ONLY_PAGES = [ "dataset", "allenite", "program", + "resource", ]; exports.createSchemaCustomization = ({ actions, schema }) => { diff --git a/src/cms/cms.js b/src/cms/cms.js index 2906919..acba4f6 100644 --- a/src/cms/cms.js +++ b/src/cms/cms.js @@ -3,7 +3,22 @@ import CMS from "decap-cms-app"; import AboutPagePreview from "./preview-templates/AboutPagePreview"; import IdeaPostPreview from "./preview-templates/IdeaPostPreview"; import IndexPagePreview from "./preview-templates/IndexPagePreview"; +import VariableResourceUnionControl from "./widgets/VariableResourceWidget/VariableResourceUnionControl"; +import copyResourceNameHandler from "./widgets/VariableResourceWidget/copyResourceNameHandler"; + +// Register custom widgets, with optional preview components +// and global styles. +CMS.registerWidget({ + name: "resource_union", + controlComponent: VariableResourceUnionControl, +}); CMS.registerPreviewTemplate("index", IndexPagePreview); CMS.registerPreviewTemplate("about", AboutPagePreview); CMS.registerPreviewTemplate("idea", IdeaPostPreview); + +// Decap exposes a number of lifecycle stages we can hook into and register. +CMS.registerEventListener({ + name: "preSave", + handler: copyResourceNameHandler, +}); diff --git a/src/cms/widgets/VariableResourceWidget/VariableResourceUnionControl.tsx b/src/cms/widgets/VariableResourceWidget/VariableResourceUnionControl.tsx new file mode 100644 index 0000000..cec1003 --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/VariableResourceUnionControl.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import VariableTypeWidgetControl from "../VariableTypeWidget/VariableTypeWidgetControl"; +import { VARIABLE_TYPE_RESOURCE_CONFIG } from "./constants"; +import type { CmsWidgetControlProps } from "decap-cms-core"; + +/** + * Implementation of VariableTypeWidgetControl for a union of different + * resource types (software tools, datasets, etc.). + * Config defined in RESOURCE_TYPES. + */ +const ResourceUnionControl = (props: CmsWidgetControlProps) => { + return ( + + ); +}; + +export default ResourceUnionControl; \ No newline at end of file diff --git a/src/cms/widgets/VariableResourceWidget/constants.ts b/src/cms/widgets/VariableResourceWidget/constants.ts new file mode 100644 index 0000000..10d6138 --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/constants.ts @@ -0,0 +1,105 @@ +import { TypeConfig } from "../VariableTypeWidget/types"; + +const SOFTWARE_STATUS_OPTIONS = [ + "Public", + "In development", + "Internal use only", +]; + +const DATASET_STATUS_OPTIONS = [ + "Public", + "Not QCed", + "Preliminary", + "Need to request data directly", +]; + +// Optional: override baseFields per type, +// defaults to name, description, link. +export const VARIABLE_TYPE_RESOURCE_CONFIG: TypeConfig[] = [ + { + value: "softwareTool", + label: "Software Tool", + fields: [ + { + label: "README/Quickstart Link", + name: "readmeLink", + type: "input", + }, + { + label: "Status", + name: "status", + type: "select", + options: SOFTWARE_STATUS_OPTIONS, + }, + ], + }, + { + value: "dataset", + label: "Dataset", + fields: [ + { + label: "Status", + name: "status", + type: "select", + options: DATASET_STATUS_OPTIONS, + }, + ], + }, + { + value: "protocolLink", + label: "Protocol (Link)", + fields: [ + { label: "Name", name: "name", type: "input" }, + { + label: "Description", + name: "description", + type: "textarea", + rows: 4, + }, + { + label: "URL", + name: "url", + type: "input", + hint: "External link to protocol", + }, + ], + }, + { + value: "protocolFile", + label: "Protocol (File)", + fields: [ + { label: "Name", name: "name", type: "input" }, + { + label: "Description", + name: "description", + type: "textarea", + rows: 4, + }, + { + label: "File Path", + name: "file", + type: "file", + hint: "Use Media Library to upload, then paste path here", + }, + ], + }, + { + value: "cellLine", + label: "Cell Line", + fields: [ + { label: "Name", name: "name", type: "input" }, + { + label: "Description", + name: "description", + type: "textarea", + rows: 4, + }, + { + label: "URL", + name: "url", + type: "input", + hint: "Link to cell catalog or documentation", + }, + ], + }, +]; diff --git a/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts b/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts new file mode 100644 index 0000000..e8acc3d --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts @@ -0,0 +1,37 @@ +import CMS from "decap-cms-core" + +// FRor some reason I could not import CmsEventListener as as a type +// and it was resolving to the global CMS object, so this is the workaround. +type CmsEventListenerHandler = Parameters[0]['handler']; +type CmsEventListenerHandlerArg = Parameters[0]; + +/** + Certain fields are required to be top-level by the CMS, which clutters the UI + when we have a redundant field in widget. This preSave hook copies the resource.name + field to the top-level from the nested widget. + */ +export const copyResourceNameHandler = ({ entry }: CmsEventListenerHandlerArg) => { + const collection = entry.get("collection"); + + // Only apply to resources collection + if (collection !== "resources") { + return entry.get("data"); + } + + const data = entry.get("data"); + const resource = data.get("resource"); + + if (resource) { + // Copy resource.name to top-level name + const resourceName = resource.get + ? resource.get("name") + : resource.name; + if (resourceName) { + return data.set("name", resourceName); + } + } + + return data; +} + +export default copyResourceNameHandler; \ No newline at end of file diff --git a/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx b/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx new file mode 100644 index 0000000..66ac8af --- /dev/null +++ b/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx @@ -0,0 +1,274 @@ +import React from "react"; +import CMS from "decap-cms-app"; +import { FieldConfig, TypeConfig } from "./types"; +import type { CmsWidgetControlProps } from "decap-cms-core"; + +interface VariableTypeWidgetControlProps extends CmsWidgetControlProps { + types: TypeConfig[]; + defaultType?: string; + baseFields?: FieldConfig[]; +} + +// Decap uses Immutable.js internally - this interface helps us +// convert their Map objects to plain JS when needed +interface ImmutableLike { + toJS: () => Record; +} + +const DEFAULT_BASE_FIELDS: FieldConfig[] = [ + { label: "Name", name: "name", type: "input" }, + { label: "Description", name: "description", type: "textarea", rows: 4 }, + { + label: "Link", + name: "link", + type: "input", + hint: "https://... ", + }, +]; + +/** + * It would be more consistent with our codestyle to use a stylesheet, but injecting + * the styles into Decap is tricky. It's possible to use css defined in a string as + * JS module, and inject it with the optional globalStyles property on the CmsWidgetParam + * when calling registerWidget if preferred. + */ +const styles = { + container: { + padding: 16, + border: "1px solid #ddd", + borderRadius: 4, + backgroundColor: "#fafafa", + } as React.CSSProperties, + fieldGroup: { + marginBottom: 16, + } as React.CSSProperties, + typeSelector: { + marginBottom: 20, + } as React.CSSProperties, + label: { + display: "block", + fontWeight: 600, + marginBottom: 4, + } as React.CSSProperties, + input: { + width: "100%", + padding: 8, + border: "1px solid #ddd", + borderRadius: 4, + boxSizing: "border-box", + } as React.CSSProperties, + hint: { + fontSize: 12, + color: "#666", + marginTop: 4, + } as React.CSSProperties, + error: { + fontSize: 12, + color: "#b00", + } as React.CSSProperties, +}; + +/** + * Union object control for resources: + * - value.type determines which fields render + * - fields are config-driven via the `types` prop + */ +const VariableTypeWidgetControl = (props: VariableTypeWidgetControlProps) => { + const { + onChange, + value, + types, + defaultType, + baseFields = DEFAULT_BASE_FIELDS, + } = props; + + /** + * Decap's onChange always expects the entire object for this control + * when values change. + * + * We use getDefaultType as a fallback to ensure there's always a valid "type" field, + * and on intial render to determine which fields to show. + * + * We call normalizeValue in handleChange to ensure we're working with a plain JS object, + * and have the current value available when any field changes. + * + * Then handle change retrieves and passes in the new value. + */ + + const getDefaultType = (): string => { + return defaultType || (types[0] ? types[0].value : ""); + }; + + const normalizeValue = (): Record => { + if (!value || typeof value === "string") { + return { type: getDefaultType() }; + } + + const obj = + typeof (value as ImmutableLike).toJS === "function" + ? (value as ImmutableLike).toJS() + : (value as Record); + + return { ...obj, type: obj.type || getDefaultType() }; + }; + + const handleChange = (field: string, newValue: unknown) => { + const current = normalizeValue(); + onChange({ ...current, [field]: newValue }); + }; + + const handleTypeChange = (newType: string) => { + onChange({ type: newType }); + }; + + const renderInput = ( + cfg: FieldConfig, + valueObj: Record, + ) => { + const { label, name, placeholder = "", hint = "" } = cfg; + return ( +
+ + handleChange(name, e.target.value)} + placeholder={placeholder} + /> + {hint &&
{hint}
} +
+ ); + }; + + const renderTextarea = ( + cfg: FieldConfig, + valueObj: Record, + ) => { + const { label, name, placeholder = "", hint = "", rows = 4 } = cfg; + return ( +
+ +