Sorry, no results found for "".

Plugin SDK > Manual field extensions

Manual field extensions

In the previous chapter:

  • we saw the different types of field extensions we can create (editors and addons);

  • we've seen how we can programmatically associate a particular extension to one (or multiple) fields;

  • we used the renderFieldExtension hook to actually render our extensions.

If you haven't read the chapter, we encourage you to do it, as we're going to build up on the same examples!

Manual field extensions vs overrideFieldExtensions

So far, we have used the overrideFieldExtensions hook to programmatically apply our extensions to fields. There is an alternative way of working with field extensions that passes through a second hook that you can implement, namely manualFieldExtensions:

import { connect, Field, ManualFieldExtensionsCtx, OverrideFieldExtensionsCtx } from 'datocms-plugin-sdk';
connect({
manualFieldExtensions(ctx: ManualFieldExtensionsCtx) {
return [
{
id: 'starRating',
name: 'Star rating',
type: 'editor',
fieldTypes: ['integer'],
},
];
},
overrideFieldExtensions(field: Field, ctx: OverrideFieldExtensionsCtx) {
if (field.attributes.field_type === 'text') {
return {
addons: [{ id: 'loremIpsumGenerator' }],
};
}
},
});

With this setup, we are still automatically applying our "Lorem ipsum" generator to every text field in our project, but the "Star rating" is becoming a manual extension. That is, it's the end-user that will have to manually apply it on one or more fields of type "integer" through the "Presentation" tab in the field settings:

When to use one strategy or the other?

At this point a question may arise... when does it make sense to force an extension with overrideFieldExtensions and when to let the user install it manually? Well, it all depends on the type of extension you're developing, and what you imagine to be the most comfortable and natural way to offer its functionality!

Let's try to think about the extensions we have developed so far, and see what would be the best strategy for them:

  • Given that the "Star rating" extension will most likely be used in a few specific spots, rather than in all integer fields of the project, letting the user manually apply it when needed feels like the best choice.

  • On the other hand, our "Lorem Ipsum generator" is probably convenient in all text fields: requiring the end user to manually install it everywhere could be unnecessarily tedious, so the choice to programmatically force the addon on all text fields is probably the right one.

If we feel that a carpet-bombing strategy for the "Lorem ipsum" extension might bee too much, and we wanted to make the installation more granular but still automatic, we could add some global settings to the plugin to allow the user to configure some application rules (ie. "only add the addon if the API key of the text field ends with _main_content"):

overrideFieldExtensions(field: Field, ctx: OverrideFieldExtensionsCtx) {
// get the suffix from plugin configuration settings
const { loremIpsumApiKeySuffix } = ctx.plugin.attributes.parameters;
if (
field.attributes.field_type === 'text' &&
field.attributes.api_key.endsWith(loremIpsumApiKeySuffix)
) {
return {
addons: [
{ id: 'loremIpsumGenerator' },
],
};
}
}

If you can't make up your mind on the best strategy for your field extension, there's always a third option: let the end user be in charge of the decision! Plugin settings are always available in every hook, so you can read the user preference and act accordingly:

import { connect, Field, ManualFieldExtensionsCtx, OverrideFieldExtensionsCtx } from 'datocms-plugin-sdk';
connect({
manualFieldExtensions(ctx: ManualFieldExtensionsCtx) {
const { autoApply } = ctx.plugin.attributes.parameters;
if (autoApply) {
return [];
}
return [
{
id: 'starRating',
name: 'Star rating',
type: 'editor',
fieldTypes: ['integer'],
},
{
id: 'loremIpsumGenerator',
name: 'Lorem Ipsum generator',
type: 'addon',
fieldTypes: ['text'],
},
];
},
overrideFieldExtensions(field: Field, ctx: OverrideFieldExtensionsCtx) {
const { autoApply } = ctx.plugin.attributes.parameters;
if (!autoApply) {
return;
}
if (field.attributes.field_type === 'text') {
return {
addons: [{ id: 'loremIpsumGenerator' }],
};
}
if (
field.attributes.field_type === 'integer' &&
field.attributes.api_key === 'rating'
) {
return {
editor: { id: 'starRating' },
};
}
},
});

Add per-field config screens to manual field extensions

In the manualFieldExtensions() hook, we can pass the configurable: true option to declare that we want to present a config screen to the user when they're installing the extension on a field:

import { connect, Field, ManualFieldExtensionsCtx } from 'datocms-plugin-sdk';
connect({
manualFieldExtensions(ctx: ManualFieldExtensionsCtx) {
return [
{
id: 'starRating',
name: 'Star rating',
type: 'editor',
fieldTypes: ['integer'],
configurable: true,
},
];
},
});

To continue our example, let's take our "Star rating" editor and say we want to offer end-users the ability, on a per-field basis, to specify the maximum number of stars that can be selected and the color of the stars.

Just like global plugin settings, these per-field configuration parameters are completely arbitrary, so it is up to the plugin itself to show the user a form through which they can be changed.

Don't use form management libraries!

Unlike the global config screen, where we manage the form ourselves, here we are "guests" inside the field edit form. That is, the submit button in the modal triggers the saving not only of our settings, but also of all the other field configurations, which we do not control.

The SDK, in this location, provides a set of very simple primitives to integrate with the form managed by the DatoCMS application, including validations. The use of React form management libraries is not suitable in this hook, as most of them are designed to "control" the form.

The hook provided to render the config screen is renderManualFieldExtensionConfigScreen, and it will be called by DatoCMS when the user adds the extension on a particular field.

Inside the hook we simply initialize React and a custom component called StarRatingConfigScreen. The argument ctx provides a series of information and methods for interacting with the main application, and for now all we just pass the whole object to the component, in the form of a React prop:

import React from 'react';
import ReactDOM from 'react-dom';
import {
connect,
RenderManualFieldExtensionConfigScreenCtx,
} from 'datocms-plugin-sdk';
connect({
renderManualFieldExtensionConfigScreen(
fieldExtensionId: string,
ctx: RenderManualFieldExtensionConfigScreenCtx,
) {
ReactDOM.render(
<React.StrictMode>
<StarRatingConfigScreen ctx={ctx} />
</React.StrictMode>,
document.getElementById('root'),
);
},
});

This is how our full component looks like:

import { RenderManualFieldExtensionConfigScreenCtx } from 'datocms-plugin-sdk';
import { Canvas, Form, TextField } from 'datocms-react-ui';
import { CSSProperties, useCallback, useState } from 'react';
type PropTypes = {
ctx: RenderManualFieldExtensionConfigScreenCtx;
};
// this is how we want to save our settings
type Parameters = {
maxRating: number;
starsColor: NonNullable<CSSProperties['color']>;
};
function StarRatingConfigScreen({ ctx }: PropTypes) {
const [formValues, setFormValues] = useState<Partial<Parameters>>(
ctx.parameters,
);
const update = useCallback((field, value) => {
const newParameters = { ...formValues, [field]: value };
setFormValues(newParameters);
ctx.setParameters(newParameters);
}, [formValues, setFormValues, ctx.setParameters]);
return (
<Canvas ctx={ctx}>
<Form>
<TextField
id="maxRating"
name="maxRating"
label="Maximum rating"
required
value={formValues.maxRating}
onChange={update.bind(null, 'maxRating')}
/>
<TextField
id="starsColor"
name="starsColor"
label="Stars color"
required
value={formValues.starsColor}
onChange={update.bind(null, 'starsColor')}
/>
</Form>
</Canvas>
);
}

Here's how it works:

  • we use ctx.parameters as the initial value for our internal state formValues;

  • as the user changes values for the inputs, we're use ctx.setParameters() to propagate the change to the main DatoCMS application (as well as updating our internal state).

Always use the canvas!

It is important to wrap the content inside the Canvas component, so that the iframe will continuously auto-adjust its size based on the content we're rendering, and to give our app the look and feel of the DatoCMS web app.

Enforcing validations on configuration options

Users might insert invalid values for the options we present. We can implement another hook called validateManualFieldExtensionParameters to enforce some validations on them:

const isValidCSSColor = (strColor: string) => {
const s = new Option().style;
s.color = strColor;
return s.color !== '';
};
connect({
validateManualFieldExtensionParameters(
fieldExtensionId: string,
parameters: Record<string, any>,
) {
const errors: Record<string, string> = {};
if (
isNaN(parseInt(parameters.maxRating)) ||
parameters.maxRating < 2 ||
parameters.maxRating > 10
) {
errors.maxRating = 'Rating must be between 2 and 10!';
}
if (!parameters.starsColor || !isValidCSSColor(parameters.starsColor)) {
errors.starsColor = 'Invalid CSS color!';
}
return errors;
},
});

Inside our component, we can access those errors and present them below the input fields:

function StarRatingParametersForm({ ctx }: PropTypes) {
const errors = ctx.errors as Partial<Record<string, string>>;
// ...
return (
<Canvas ctx={ctx}>
<TextField
id="maxRating"
/* ... */
error={errors.maxRating}
/>
<TextField
id="starsColor"
/* ... */
error={errors.starsColor}
/>
</Canvas>
);
}

This is the final result:

Now that we have some settings, we can access them in the renderFieldExtension hook through the ctx.parameters object, and use them to configure the star rating component:

import ReactStars from 'react-rating-stars-component';
function StarRatingEditor({ ctx }: PropTypes) {
// ...
return (
<ReactStars
/* ... */
count={ctx.parameters.maxRating}
activeColor={ctx.parameters.starsColor}
/>
);
}

manualFieldExtensions(ctx)

Use this function to declare new field extensions that users will be able to install manually in some field.

Return value

The function must return: ManualFieldExtension[].

Context object

The following properties and methods are available in the ctx argument:

Every hook available in the Plugin SDK shares the same minumum set of properties and methods.

Information about the current user using the CMS.

The current DatoCMS user. It can either be the owner or one of the collaborators (regular or SSO).

View on Github

The role for the current DatoCMS user.

View on Github

The access token to perform API calls on behalf of the current user. Only available if currentUserAccessToken additional permission is granted.

View on Github
These methods can be used to open custom dialogs/confirmation panels.

Opens a custom modal. Returns a promise resolved with what the modal itself returns calling the resolve() function.

View on Github
const result = await ctx.openModal({
id: 'regular',
title: 'Custom title!',
width: 'l',
parameters: { foo: 'bar' },
});
if (result) {
ctx.notice(`Success! ${JSON.stringify(result)}`);
} else {
ctx.alert('Closed!');
}

Opens a UI-consistent confirmation dialog. Returns a promise resolved with the value of the choice made by the user.

View on Github
const result = await ctx.openConfirm({
title: 'Custom title',
content:
'Lorem Ipsum is simply dummy text of the printing and typesetting industry',
choices: [
{
label: 'Positive',
value: 'positive',
intent: 'positive',
},
{
label: 'Negative',
value: 'negative',
intent: 'negative',
},
],
cancel: {
label: 'Cancel',
value: false,
},
});
if (result) {
ctx.notice(`Success! ${result}`);
} else {
ctx.alert('Cancelled!');
}
These properties provide access to "entity repos", that is, the collection of resources of a particular type that have been loaded by the CMS up to this moment. The entity repos are objects, indexed by the ID of the entity itself.

All the models of the current DatoCMS project, indexed by ID.

View on Github

All the fields currently loaded for the current DatoCMS project, indexed by ID. If some fields you need are not present, use the loadItemTypeFields function to load them.

View on Github

All the fieldsets currently loaded for the current DatoCMS project, indexed by ID. If some fields you need are not present, use the loadItemTypeFieldsets function to load them.

View on Github

All the regular users currently loaded for the current DatoCMS project, indexed by ID. It will always contain the current user. If some users you need are not present, use the loadUsers function to load them.

View on Github

All the SSO users currently loaded for the current DatoCMS project, indexed by ID. It will always contain the current user. If some users you need are not present, use the loadSsoUsers function to load them.

View on Github
These methods let you open the standard DatoCMS dialogs needed to interact with records.

Opens a dialog for creating a new record. It returns a promise resolved with the newly created record or null if the user closes the dialog without creating anything.

View on Github
const itemTypeId = prompt('Please insert a model ID:');
const item = await ctx.createNewItem(itemTypeId);
if (item) {
ctx.notice(`Success! ${item.id}`);
} else {
ctx.alert('Closed!');
}

Opens a dialog for selecting one (or multiple) record(s) from a list of existing records of type itemTypeId. It returns a promise resolved with the selected record(s), or null if the user closes the dialog without choosing any record.

View on Github
const itemTypeId = prompt('Please insert a model ID:');
const items = await ctx.selectItem(itemTypeId, { multiple: true });
if (items) {
ctx.notice(`Success! ${items.map((i) => i.id).join(', ')}`);
} else {
ctx.alert('Closed!');
}

Opens a dialog for editing an existing record. It returns a promise resolved with the edited record, or null if the user closes the dialog without persisting any change.

View on Github
const itemId = prompt('Please insert a record ID:');
const item = await ctx.editItem(itemId);
if (item) {
ctx.notice(`Success! ${item.id}`);
} else {
ctx.alert('Closed!');
}
These methods can be used to asyncronously load additional information your plugin needs to work.

Loads all the fields for a specific model (or block). Fields will be returned and will also be available in the the fields property.

View on Github
const itemTypeId = prompt('Please insert a model ID:');
const fields = await ctx.loadItemTypeFields(itemTypeId);
ctx.notice(
`Success! ${fields
.map((field) => field.attributes.api_key)
.join(', ')}`,
);

Loads all the fieldsets for a specific model (or block). Fieldsets will be returned and will also be available in the the fieldsets property.

View on Github
const itemTypeId = prompt('Please insert a model ID:');
const fieldsets = await ctx.loadItemTypeFieldsets(itemTypeId);
ctx.notice(
`Success! ${fieldsets
.map((fieldset) => fieldset.attributes.title)
.join(', ')}`,
);

Loads all the fields in the project that are currently using the plugin for one of its manual field extensions.

View on Github
const fields = await ctx.loadFieldsUsingPlugin();
ctx.notice(
`Success! ${fields
.map((field) => field.attributes.api_key)
.join(', ')}`,
);

Loads all regular users. Users will be returned and will also be available in the the users property.

View on Github
const users = await ctx.loadUsers();
ctx.notice(`Success! ${users.map((user) => user.id).join(', ')}`);

Loads all SSO users. Users will be returned and will also be available in the the ssoUsers property.

View on Github
const users = await ctx.loadSsoUsers();
ctx.notice(`Success! ${users.map((user) => user.id).join(', ')}`);
These methods can be used to take the user to different pages.

Moves the user to another URL internal to the backend.

View on Github
await ctx.navigateTo('/');
Information about the current plugin. Useful to access the plugin's global configuration object.

The current plugin.

View on Github

The current DatoCMS project.

View on Github

The ID of the current environment.

View on Github

The account/organization that is the project owner.

View on Github

UI preferences of the current user (right now, only the preferred locale is available).

View on Github

An object containing the theme colors for the current DatoCMS project.

View on Github
These methods can be used to show UI-consistent toast notifications to the end-user.

Triggers an "error" toast displaying the selected message.

View on Github
const message = prompt(
'Please insert a message:',
'This is an alert message!',
);
await ctx.alert(message);

Triggers a "success" toast displaying the selected message.

View on Github
const message = prompt(
'Please insert a message:',
'This is a notice message!',
);
await ctx.notice(message);

Triggers a custom toast displaying the selected message (and optionally a CTA).

View on Github
const result = await ctx.customToast({
type: 'warning',
message: 'Just a sample warning notification!',
dismissOnPageChange: true,
dismissAfterTimeout: 5000,
cta: {
label: 'Execute call-to-action',
value: 'cta',
},
});
if (result === 'cta') {
ctx.notice(`Clicked CTA!`);
}
These methods can be used to update both plugin parameters and manual field extensions configuration.

Updates the plugin parameters.

Always check ctx.currentRole.meta.final_permissions.can_edit_schema before calling this, as the user might not have the permission to perform the operation.

View on Github
await ctx.updatePluginParameters({ debugMode: true });
await ctx.notice('Plugin parameters successfully updated!');

Performs changes in the appearance of a field. You can install/remove a manual field extension, or tweak their parameters. If multiple changes are passed, they will be applied sequencially.

Always check ctx.currentRole.meta.final_permissions.can_edit_schema before calling this, as the user might not have the permission to perform the operation.

View on Github
const fields = await ctx.loadFieldsUsingPlugin();
if (fields.length === 0) {
ctx.alert('No field is using this plugin as a manual extension!');
return;
}
for (const field of fields) {
const { appearance } = field.attributes;
const operations = [];
if (appearance.editor === ctx.plugin.id) {
operations.push({
operation: 'updateEditor',
newParameters: {
...appearance.parameters,
foo: 'bar',
},
});
}
appearance.addons.forEach((addon, i) => {
if (addon.id !== ctx.plugin.id) {
return;
}
operations.push({
operation: 'updateAddon',
index: i,
newParameters: { ...addon.parameters, foo: 'bar' },
});
});
await ctx.updateFieldAppearance(field.id, operations);
ctx.notice(`Successfully edited field ${field.attributes.api_key}`);
}
These methods let you open the standard DatoCMS dialogs needed to interact with Media Area assets.

Opens a dialog for selecting one (or multiple) existing asset(s). It returns a promise resolved with the selected asset(s), or null if the user closes the dialog without selecting any upload.

View on Github
const item = await ctx.selectUpload({ multiple: false });
if (item) {
ctx.notice(`Success! ${item.id}`);
} else {
ctx.alert('Closed!');
}

Opens a dialog for editing a Media Area asset. It returns a promise resolved with:

  • The updated asset, if the user persists some changes to the asset itself
  • null, if the user closes the dialog without persisting any change
  • An asset structure with an additional deleted property set to true, if the user deletes the asset.
View on Github
const uploadId = prompt('Please insert an asset ID:');
const item = await ctx.editUpload(uploadId);
if (item) {
ctx.notice(`Success! ${item.id}`);
} else {
ctx.alert('Closed!');
}

Opens a dialog for editing a "single asset" field structure. It returns a promise resolved with the updated structure, or null if the user closes the dialog without persisting any change.

View on Github
const uploadId = prompt('Please insert an asset ID:');
const result = await ctx.editUploadMetadata({
upload_id: uploadId,
alt: null,
title: null,
custom_data: {},
focal_point: null,
});
if (result) {
ctx.notice(`Success! ${JSON.stringify(result)}`);
} else {
ctx.alert('Closed!');
}

validateManualFieldExtensionParameters(fieldExtensionId: string, parameters: Record<string, unknown>)

This function will be called each time the configuration object changes. It must return an object containing possible validation errors.

Return value

The function must return: Record<string, unknown> | Promise<Record<string, unknown>>.