diff --git a/gatsby-node.js b/gatsby-node.js index 0500bde..312d9b9 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -18,7 +18,13 @@ const read = (p) => fs.readFileSync(path.join(__dirname, p), "utf8"); * They serve as single source of truth, can be added/edited via CMS, * and are referenced by other markdown files. */ -const DATA_ONLY_PAGES = ["software", "dataset", "allenite", "program"]; +const DATA_ONLY_PAGES = [ + "software", + "dataset", + "allenite", + "program", + "resource", +]; exports.createSchemaCustomization = ({ actions, schema }) => { const { createTypes } = actions; diff --git a/src/cms/cms.js b/src/cms/cms.js index 2906919..4dd1a06 100644 --- a/src/cms/cms.js +++ b/src/cms/cms.js @@ -1,9 +1,25 @@ import CMS from "decap-cms-app"; +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..f225057 --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/VariableResourceUnionControl.tsx @@ -0,0 +1,31 @@ +import React from "react"; + + + +import type { CmsWidgetControlProps } from "decap-cms-core"; + + + +import VariableTypeWidgetControl from "../VariableTypeWidget/VariableTypeWidgetControl"; +import { VARIABLE_TYPE_RESOURCE_CONFIG } from "./constants"; + + + + + +/** + * Implementation of VariableTypeWidgetControl for a union of different + * resource types (software tools, datasets, etc.). + * Config defined in VARIABLE_TYPE_RESOURCE_CONFIG. + */ +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..a5a9a81 --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/constants.ts @@ -0,0 +1,70 @@ +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: [], + }, + { + value: "protocolFile", + label: "Protocol (File)", + fields: [ + { + label: "File Path", + name: "file", + type: "file", + hint: "Use Media Library to upload, then paste path here", + }, + ], + }, + { + value: "cellLine", + label: "Cell Line", + fields: [], + }, +]; diff --git a/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts b/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts new file mode 100644 index 0000000..d9cf7c2 --- /dev/null +++ b/src/cms/widgets/VariableResourceWidget/copyResourceNameHandler.ts @@ -0,0 +1,41 @@ +import CMS from "decap-cms-core"; + +// For some reason I could not import CmsEventListener as a type +// because it was resolving to the global CMS object, so this is the workaround. +type CmsEventListenerHandler = Parameters< + typeof CMS.registerEventListener +>[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("resourceDetails"); + + 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; diff --git a/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx b/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx new file mode 100644 index 0000000..0780585 --- /dev/null +++ b/src/cms/widgets/VariableTypeWidget/VariableTypeWidgetControl.tsx @@ -0,0 +1,277 @@ +import React from "react"; + +import CMS from "decap-cms-app"; +import type { CmsWidgetControlProps } from "decap-cms-core"; + +import { FieldConfig, TypeConfig } from "./types"; + +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's easier to apply styles directly here in the component, than to import a stylesheet, + * and make sure ensure those styles are correctly injected in the Decap build. + * + * To inject styles refer to globalStyles documentation on registerWidget params. + * + */ +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 ( +
+ +