Skip to main content

Create a Webiny Headless CMS address field plugin

What you'll learn
  • How to create a new content model field plugin
  • How a plugin stores and retrieves data

Overview#

A detailed and more in-depth explanation and how-to for a Webiny Headless CMS field plugins. As an example, in this tutorial you will create a new field plugin that uses Google Maps API to retrieve a location or an address. In this tutorial we assume that you know how Webiny plugins work.

Tutorial example files are located in our example repository.

Tutorial narrative#

You need a field that gives you a functionality to search for an address via Google Maps API. When you select the address you want, from the list the Google Maps API provided, it is set to the form data. Also, you do not want to index that field, and you want to encrypt it before saving into the storage.

The placement of the plugins#

By all means, you can create plugins where ever you want - but try to stick to some structure. You have api and apps directories, so put your plugins in those, according to a plugin type. Our suggestion would be something like this:

  1. [UI] plugins folder - apps/admin/code/src/plugins/headlessCMS/fields/address/
  2. [API] plugins folder - api/code/headlessCMS/src/fields/address/

Your field type is a name of the directory containing all the plugins for that type. Remember, there are multiple types of plugins for a single field type in both UI and API side.

Files we are creating are:

  1. addressFieldPlugin.ts - API field definition
  2. addressFieldStoragePlugin.ts - API storage modifications
  3. addressFieldIndexPlugin.ts - API indexing modifications
  4. addressFieldPlugin.tsx - UI field definition and display when creating a model
  5. addressFieldRendererPlugin.tsx - UI display of the field when creating or updating the entry
info

For quick info on required plugins check our How-to Guide: Create a Webiny Headless CMS field plugin.

UI plugins#

Definition plugin#

First we need to create a field definition plugin. The base for the plugin is:

[UI]/addressFieldPlugin.tsx
export default(): CmsEditorFieldTypePlugin => ({
type: "cms-editor-field-type",
name: "cms-editor-field-type-address",
field: {
type: "address",
label: "Address",
description: "Search for the address",
icon: <AddressIcon />,
allowMultipleValues: false,
allowPredefinedValues: false,
multipleValuesLabel: "Use as list of addresses",
createField() {}
}
});

And in the createField() function we return the field data:

[UI]/addressFieldPlugin.tsx
createField() {
return {
type: "address",
validation: [],
renderer: {
name: ""
}
};
}

Note that renderer name is left blank so code automatically determines which renderer to use. You can put the name of the renderer, but for our tutorial we leave it blank and use it later.

Renderer plugin#

Now we can create the second UI plugin, a renderer for the field we just created. A base for the renderer plugin is:

[UI]/addressFieldRendererPlugin.tsx
export default(): CmsEditorFieldRendererPlugin => ({
type: "cms-editor-field-renderer",
name: "cms-editor-field-renderer-address",
renderer: {
rendererName: "addressRenderer",
name: `Address search`,
description: `Search for the address.`,
canUse({ field }) {},
render({ field, getBind }) {}
}
});

Remember that we left the renderer.name property blank when we created the field? Now in the canUse() function you put the condition that determines if that particular renderer is for a given field:

[UI]/addressFieldRendererPlugin.tsx
canUse({ field }) {
return field.type === "address";
}

In the render() function you put what is actually displayed when the field rendering is called. We can create a onSelect() function that actually triggers the field change - bind.onChange().

[UI]/addressFieldRendererPlugin.tsx
render({ field, getBind }) {
const Bind = getBind();
const onSelect = (bind, {coordinates, ...address}) => {
bind.onChange({
address,// an object containing country, city, zipCode, street and streetNumber
coordinates
});
};
return (
<Bind>
{bind => (
<AddressSearch
{...bind}
onSelect={(value) => onSelect(bind, value)}
label={field.label}
placeholder="Type to find the address."
/>
)}
</Bind>
);
}

API plugins#

Definition plugin#

Now let's go solve the API plugins. First we need to create a plugin of type CmsContentModelField. This is the base of that plugin:

[API]/addressFieldPlugin.ts
export default(): CmsModelFieldToGraphQLPlugin => ({
type: "cms-model-field-to-graphql",
name: "cms-model-field-to-graphql-address",
fieldType: "address",
isSortable: false,
isSearchable: false,
read: {},
manage: {}
});

Read definitions#

In the read definition, for the read and preview API, we can have a user-friendly GraphQL field:

[API]/addressFieldPlugin.ts
read: {
createSchema() {
return {
typeDefs: `
type Address {
country: String
city: String
zipCode: String
street: String
streetNumber: String
coordinates: String
}
`,
resolvers: {}
};
},
createTypeField({ field }) {
return `${field.fieldId}: Address`;
}
}

And the resolver for that field:

[API]/addressFieldPlugin.ts
read: {
createResolver({ field }) {
return instance => {
const value = instance.values[field.fieldId];
// there is a possibility that value is not populated
// so we cannot destructure the object because code will break
if (!value) {
return {};
}
const {address, coordinates} = value;
const {country, city, zipCode, street, streetNumber} = address;
return {
country,
city,
zipCode,
street,
streetNumber,
coordinates
};
};
}
}

Since we do not want the field to be filtered, we do not need to define createGetFilters or createListFilters.

Manage definitions#

The manage side of the API can be exactly the same as the read one, but in our case it is a bit different. Since we save plain JSON value, this part is quite simple:

[API]/addressFieldPlugin.ts
manage: {
createTypeField({ field }) {
return `${field.fieldId}: JSON`;
},
createInputField({ field }) {
return `${field.fieldId}: JSON`;
}
}

Indexing plugin#

Now we have everything required to create and save the field. Next thing we need is to prevent the indexing of the field. By default, if a field is not searchable it is removed from the index. But for this tutorial we can create our own plugin. This is the base of that plugin:

[API]/addressFieldIndexPlugin.ts
export default (): CmsModelFieldToElasticsearchPlugin => ({
type: "cms-model-field-to-elastic-search",
name: "cms-model-field-to-elastic-search-address",
fieldType: "address",
toIndex(args) {},
fromIndex(args) {}
});

In toIndex we must remove the field from values and put it into non-indexable rawValues object:

[API]/addressFieldIndexPlugin.ts
toIndex({field, toIndexEntry}) {
const values = toIndexEntry.values;
const value = values[field.fieldId];
delete values[field.fieldId];
return {
values,
rawValues: {
...(toIndexEntry.rawValues || {}),
[field.fieldId]: value
}
};
}

And in fromIndex we must revert that action:

[API]/addressFieldIndexPlugin.ts
fromIndex({ field, entry }) {
const rawValues = entry.rawValues || {};
const value = rawValues[field.fieldId];
delete rawValues[field.fieldId];
return {
values: {
...(entry.values || {}),
[field.fieldId]: value
},
rawValues
};
}

This plugin now does what we want - disables the field indexing. Of course, we could pack the field with jsonpack or compress it in the toIndex method to take less space. The choice is all yours, just remember to revert what ever action you do.

Storage plugin#

All we have left to write is the plugin for storage. As we said, we want to encrypt the data for the storage. You can what ever library you want, it is all up to you. For this tutorial we can use some custom encrypt and decrypt functions. The base of the plugin looks like this:

[API]/addressFieldStoragePlugin.ts
export default (): CmsModelFieldToStoragePlugin => ({
type: "cms-model-field-to-storage",
name: "cms-model-field-to-storage-address",
fieldType: "address",
async fromStorage({ field, value }) {},
async toStorage({ value }) {}
});

First, we encrypt the data. Our suggestion is to encrypt the data and return an object with encrypted value and method of encryption:

[API]/addressFieldStoragePlugin.ts
async toStorage({ value }) {
return {
encryption: "webiny",
value: encrypt(value)
};
}

Of course if you want to just return the encrypted value, feel free, the choice is yours.

And then comes the decryption. Because we expect our value to have some structure, it is easy to check if decryption is necessary. Or if we need to throw an error due to something we expect is not there.

[API]/addressFieldStoragePlugin.ts
async fromStorage({ field, value }) {
if (!value) {
return value;
} else if (typeof value !== "object") {
throw new Error("It seems that value received is not an object.");
} else if (!value.encryption) {
throw new Error("Missing type of the encryption in the value object.");
} else if (value.encryption !== "webiny") {
throw new Error(`This plugin cannot transform something not encrypted with "webiny".`);
}
return decrypt(value.value);
}

You should now have a functioning field, apart the AddressSearch component and encrypt/decrypt functions - it is up to you to created them.

Last steps#

Remember that you need to import the plugins. Default import file location for API is api/code/headlessCMS/src/index.ts and for UI it is apps/admin/code/src/plugins/headlessCms.ts. If you changed that, import in these locations.

api/code/headlessCMS/src/index.ts
import { DocumentClient } from "aws-sdk/clients/dynamodb";
import { createHandler } from "@webiny/handler-aws";
import i18nPlugins from "@webiny/api-i18n/graphql";
import i18nContentPlugins from "@webiny/api-i18n-content/plugins";
import dbPlugins from "@webiny/handler-db";
import { DynamoDbDriver } from "@webiny/db-dynamodb";
import elasticSearch from "@webiny/api-plugin-elastic-search-client";
import headlessCmsPlugins from "@webiny/api-headless-cms/content";
import securityPlugins from "./security";
// import your field plugins
import addressFieldPlugin from "./fields/address/addressFieldPlugin";
import addressFieldStoragePlugin from "./fields/address/addressFieldStoragePlugin";
import addressFieldIndexPlugin from "./addressFieldIndexPlugin";
export const handler = createHandler(
elasticSearch({ endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` }),
dbPlugins({
table: process.env.DB_TABLE,
driver: new DynamoDbDriver({
documentClient: new DocumentClient({
convertEmptyValues: true,
region: process.env.AWS_REGION
})
})
}),
securityPlugins(),
i18nPlugins(),
i18nContentPlugins(),
headlessCmsPlugins({ debug: Boolean(process.env.DEBUG) }),
// add plugins to handler
addressFieldPlugin(),
addressFieldStoragePlugin(),
addressFieldIndexPlugin()
);
apps/admin/code/src/plugins/headlessCms.ts
import headlessCmsPlugins from "@webiny/app-headless-cms/admin/plugins";
// .
// .
// .
import richTextEditor from "./headlessCMS/richTextEditor";
// import your field plugins
import addressFieldPlugin from "./headlessCMS/fields/address/addressFieldPlugin";
import addressFieldRendererPlugin from "./headlessCMS/fields/address/addressFieldRendererPlugin";
export default [
// .
// .
// .
richTextEditor,
// add plugins to export
addressFieldPlugin(),
addressFieldRendererPlugin()
];
danger

Do not forget to rebuild, redeploy and rerun your application.

build api-headless-cms package
cd your-project-root
yarn webiny ws run build --folder=packages/api-headless-cms
build app-headless-cms package
cd your-project-root
yarn webiny ws run build --folder=packages/app-headless-cms
redeploy your api
cd your-project-root
yarn webiny deploy api --env=YOUR_ENVIRONMENT

... and

rerun admin app if you are running locally
cd your-project-root/apps/admin/code/
yarn start --env=YOUR_ENVIRONMENT

... or

redeploy if you are running in the cloud
cd your-project-root
yarn webiny deploy apps/admin --env=YOUR_ENVIRONMENT
Last updated on by Adrian Smijulj