Including UI Support
Editions: Open Source · Free Cloud · Pro
This is the Cloudinary Console story: sidebar, gallery, settings, and a model field. Official plugins ship a compiled ui.js. Third-party plugins do not execute JS in Console.
Manifest — contributions.ui and fields
ui:
ui_available: true
routes:
- path: /
title: Cloudinary
navigation:
- after: storage
label: Cloudinary
path: /console/plugin/hc-cloudinary-plugin
settings:
- path: /console/settings/plugins/hc-cloudinary-plugin
label: Cloudinary
fields:
- id: cloudinary_asset
label: Cloudinary Asset
icon: image
storage_type: media
form_component: CloudinaryAssetPicker
display_component: CloudinaryAssetPreview
content_form: true
Allowed after: anchors: content, database, model, users, storage, graphql, rest, auth, logic, plugins, settings.
Cloudinary uses after: storage so Files then Cloudinary in the project sidebar.

Settings path is /console/settings/plugins/hc-cloudinary-plugin. Field id cloudinary_asset stores as core storage_type: media plus plugin_id — never a new FIELD_TYPE_ENUM.

Bundle — ui/ → ui.js IIFE
esbuild marks react and antd as window.React / window.antd. Those must be the Console-bundled copies. installPluginSDK overwrites any CDN React. Dual React trees = dead Ant Design click handlers.
build.onLoad({ filter: /.*/, namespace: "window-globals" }, (args) => {
if (args.path === "react") {
return { contents: "module.exports = window.React;", loader: "js" };
}
return { contents: "module.exports = window.antd;", loader: "js" };
});
Register from ui/src/index.tsx:
sdk().register({
name: PLUGIN_ID,
version: "0.0.5",
displayName: "Cloudinary",
routes: [{ path: "/", component: "LibraryPage", title: "Cloudinary" }],
components: {
pages: { LibraryPage },
forms: {
CloudinaryAssetPicker: AssetField,
CloudinaryAssetPreview: AssetField,
},
},
fields: [
{
type: "cloudinary_asset",
label: "Cloudinary Asset",
icon: "image",
formComponent: "CloudinaryAssetPicker",
displayComponent: "CloudinaryAssetPreview",
pluginId: PLUGIN_ID,
storageType: "media",
},
],
});
host.rest(PLUGIN_ID).get/post talks to plugin REST (/plugin/hc-cloudinary-plugin/...). Gallery: library.tsx lists GET /assets; cloudinary-upload.ts POST /sign then uploads to Cloudinary. Field picker: asset-field.tsx.

Hard lesson: file inputs
Do not wrap Ant Design Upload with a display:none file input. Chrome ignores synthetic input.click() on hidden file inputs, so the button looks dead.
Cloudinary overlays an opacity-0 <input type="file"> on the button (ui/src/file-button.tsx):
<span style={{ position: "relative", display: "inline-block" }}>
<Button type="primary" htmlType="button">{label}</Button>
<input
type="file"
accept="image/*"
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) await onFile(file);
}}
/>
</span>
Skip native OS file-dialog screenshots when automating — Playwright intercepts choosers.
Load rules
Engine SealUIBundle hashes ui.js. Console PluginRegistry loads the blob only if all are true:
officialsigned- plugin activated on this project
- SHA-256 matches
bundle_sha256fromGET /system/plugin/manifest
Mismatch → declarative fallback (“no signed bundle”). After replacing ui.js locally, Engine must reload so the hash reseals.
Next: How to Publish a Plugin.