|
| 1 | +--- |
| 2 | +title: "Register New Document Types with Corresponding Editor" |
| 3 | +linktitle: "Introduce New Document Types" |
| 4 | +url: /apidocs-mxsdk/apidocs/web-extensibility-api-11/custom-blob-document-api/ |
| 5 | +--- |
| 6 | + |
| 7 | +## Introduction |
| 8 | + |
| 9 | +This how-to describes how to introduce a new document type and provide a custom editor to allow users to edit new documents of the introduced type. |
| 10 | + |
| 11 | +## Prerequisites |
| 12 | + |
| 13 | +Before starting this how-to, make sure you have completed the following prerequisites: |
| 14 | + |
| 15 | +* [Get Started with the Web Extensibility API](/apidocs-mxsdk/apidocs/web-extensibility-api-11/getting-started/). |
| 16 | + |
| 17 | +## Custom Document Model |
| 18 | + |
| 19 | +Studio Pro provides you with possibility to extend its metamodel by introducing your own document types. These new documents are allowed to store arbitrary data, |
| 20 | +which must be serializable as strings. If an editor, which is a user-defined UI component, is registered for a document, new document will appear in UI as any other |
| 21 | +built-in document type (e.g., constants, Java Actions or pages). In particular, it will appear in New Document and Find Advanced dialogs, context menus for adding |
| 22 | +new documents, App explorer and other UI elements that show Studio Pro documents. Custom editors can be registered as either tabs or modal dialogs. |
| 23 | + |
| 24 | +## Registering a New Document Type |
| 25 | + |
| 26 | +To start with registering a new document type, generate a new extension called `myextension`, as described in [getting started guide](/apidocs-mxsdk/apidocs/web-extensibility-api-11/getting-started/). |
| 27 | +We will explain which files of the generated extension to edit and then explain what the edits achieve. |
| 28 | + |
| 29 | +First, replace the contents of `src/main/index.ts` with: |
| 30 | + |
| 31 | +```typescript {hl_lines=["8-24"]} |
| 32 | +import { IComponent, getStudioProApi } from "@mendix/extensions-api"; |
| 33 | +import { personDarkThemeIcon, personDocumentType, personLightThemeIcon } from "../model/constants"; |
| 34 | +import { PersonInfo } from "../model/PersonInfo"; |
| 35 | + |
| 36 | +export const component: IComponent = { |
| 37 | + async loaded(componentContext) { |
| 38 | + const studioPro = getStudioProApi(componentContext); |
| 39 | + await studioPro.app.model.customBlobDocuments.registerDocumentType<PersonInfo>({ |
| 40 | + type: personDocumentType, |
| 41 | + readableTypeName: 'Person', |
| 42 | + defaultContent: { |
| 43 | + firstName: '', |
| 44 | + lastName: '', |
| 45 | + age: 0, |
| 46 | + email: '' |
| 47 | + } |
| 48 | + }); |
| 49 | + await studioPro.ui.editors.registerEditorForCustomDocument({ |
| 50 | + documentType: personDocumentType, |
| 51 | + editorEntryPoint: 'editor', |
| 52 | + editorKind: 'tab', |
| 53 | + iconLight: personLightThemeIcon, |
| 54 | + iconDark: personDarkThemeIcon |
| 55 | + }) |
| 56 | + |
| 57 | + } |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +Then add a new file `src/model/contants.ts` with contents: |
| 62 | + |
| 63 | +```typescript |
| 64 | +export const personDocumentType = 'myextension.Person'; |
| 65 | +export const personLightThemeIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAGKADAAQAAAABAAAAGAAAAADiNXWtAAABKElEQVRIDd2Vyw3CMBBEAxIUAWVQBxIcKIBiuNAAFVAIV2iAA2cKoAGYF9nIctaxscIBRhrZ2Z3d9T9N8++YaoIb8ShexYcjfWz40FRhraib+MwQDdpijKXci7nEsZ8YYrOoSe6LEdsLpurFtW1yudiskjXPFSaHufGciFxwqZ9cLcJNWXnjAK2Zi7NdOsKcjlwdcIlygaV+crUIl8jbwnauj5F4CY2ujw0fmiTCAndDtXC2g+HzNq8JJVau9m2Jl+CkKAYxEbfi2ZE+Nnxo4jjeqQ5Sx3QnZThTH4gNX5yc7/cx9WLavovGKJfizJG+NXKSJy+afO2raI3oE1vyqaAA+OpjRwHWtqYIMdZekdMEUy15/NBkl8WsICMbz4ng2A3+y1TOH8ALNqHxhf/P+xwAAAAASUVORK5CYII='; |
| 66 | +export const personDarkThemeIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAWdJREFUSIm1ljFuwkAQRd/giFTkABS5gMsolBRcIFBwCOTGNUfgDtDRJ9yDioaCKlJ8B0dYmyLjZGLtrh0Jj7SyNPP3f894dtbinHP0aIM+yQHuYkERuQdegDnwBIw1VABH4BV4c86VQRIXMGABXADXsi7AIsjjIR4AG0NwAnIgBUa6UvWdDG4DDLoI1OQlkAFJJMtEMWUtEhXQstTksxCxR2hmRP6UCwMamppnXcnN/sx8k6FPYGlqHixLRCAx32RZ++05mOtz65y7Btsu3I1XYNvgwmZwJty1XbNINYOzL4MxgIg8/Pftjb1bLmgZFSJSiAgiMvHEJhorYhxWoAY+Gt9RnyvP3lUDY/f+ipr67fmuX258U6ACPoEd8Kxrp74KmBp8rhz7H58JetsUWCtRcwZVwLqtTTsdNM3kAHzoOtg3V0z8oCmov1FhwP0NO93U77g2Qje5cETJvHaLKzMqcAvr/a/iC+JcVEP5CMhEAAAAAElFTkSuQmCC'; |
| 67 | +``` |
| 68 | + |
| 69 | +and another file `src/model/PersonInfo.ts` in the same directory with contents |
| 70 | + |
| 71 | +```typescript |
| 72 | +export type PersonInfo = { |
| 73 | + firstName: string; |
| 74 | + lastName: string; |
| 75 | + age: number; |
| 76 | + email: string; |
| 77 | +} |
| 78 | +``` |
| 79 | +
|
| 80 | +Then rename the file `src/ui/index.tsx` to `src/ui/editor.tsx` and paste the following contents into it: |
| 81 | +
|
| 82 | +```typescript {hl_lines=["16-22", "24-35", "37-43"]} |
| 83 | +import React, { StrictMode, useCallback, useEffect, useState } from "react"; |
| 84 | +import { createRoot } from "react-dom/client"; |
| 85 | +import { getStudioProApi, IComponent, StudioProApi } from "@mendix/extensions-api"; |
| 86 | +import type { PersonInfo } from "../model/PersonInfo"; |
| 87 | + |
| 88 | +function PersonEditor(input : { studioPro: StudioProApi, documentId: string }) { |
| 89 | + const {studioPro,documentId} = input; |
| 90 | + const [person, setPerson] = useState<PersonInfo>({ |
| 91 | + firstName: "", |
| 92 | + lastName: "", |
| 93 | + age: 0, |
| 94 | + email: "", |
| 95 | + }); |
| 96 | + const [documentVersion, setDocumentVersion] = useState(0); // Used to trigger re-fetching the document |
| 97 | + |
| 98 | + useEffect(() => { |
| 99 | + studioPro.app.model.customBlobDocuments.addEventListener("documentsChanged", ({ documents }) => { |
| 100 | + if (documents.some(doc => doc.id === documentId)) { |
| 101 | + setDocumentVersion(v => v + 1); // Trigger re-fetch of the document |
| 102 | + } |
| 103 | + }); |
| 104 | + }, [studioPro]); |
| 105 | + |
| 106 | + useEffect(() => { |
| 107 | + studioPro.app.model.customBlobDocuments |
| 108 | + .getDocumentById<PersonInfo>(documentId) |
| 109 | + .then(documentFromModel => { |
| 110 | + if (documentFromModel && !("error" in documentFromModel)) { |
| 111 | + setPerson(documentFromModel.document.contents); |
| 112 | + } |
| 113 | + }) |
| 114 | + .catch(err => { |
| 115 | + studioPro.ui.messageBoxes.show("error", "Error loading document", "Details: " + err?.message || err); |
| 116 | + }); |
| 117 | + }, [studioPro, documentId, documentVersion]); |
| 118 | + |
| 119 | + const savePerson = useCallback(async () => { |
| 120 | + try { |
| 121 | + await studioPro.app.model.customBlobDocuments.updateDocumentContent<PersonInfo>(documentId, person) |
| 122 | + } catch (error) { |
| 123 | + studioPro.ui.messageBoxes.show("error", "Error saving document", "Details: " + ((error as {message?: string})?.message || error)); |
| 124 | + } |
| 125 | + }, [studioPro, documentId, person]); |
| 126 | + |
| 127 | + const labelStyle = { display: 'inline-block', width: '300px' }; |
| 128 | + |
| 129 | + return ( |
| 130 | + <div style={{ backgroundColor: 'white', padding: '20px' }}> |
| 131 | + <h2>Person Editor</h2> |
| 132 | + <div> |
| 133 | + <label style={labelStyle}> |
| 134 | + First name: |
| 135 | + <input |
| 136 | + type="text" |
| 137 | + value={person.firstName} |
| 138 | + onChange={e => setPerson({ ...person, firstName: e.target.value })} |
| 139 | + /> |
| 140 | + </label> |
| 141 | + </div> |
| 142 | + <div> |
| 143 | + <label style={labelStyle}> |
| 144 | + Last name: |
| 145 | + <input |
| 146 | + type="text" |
| 147 | + value={person.lastName} |
| 148 | + onChange={e => setPerson({ ...person, lastName: e.target.value })} |
| 149 | + /> |
| 150 | + </label> |
| 151 | + </div> |
| 152 | + <div> |
| 153 | + <label style={labelStyle}> |
| 154 | + Age: |
| 155 | + <input |
| 156 | + type="number" |
| 157 | + value={person.age} |
| 158 | + onChange={e => setPerson({ ...person, age: Number(e.target.value) })} |
| 159 | + /> |
| 160 | + </label> |
| 161 | + </div> |
| 162 | + <div> |
| 163 | + <label style={labelStyle}> |
| 164 | + Email: |
| 165 | + <input |
| 166 | + type="email" |
| 167 | + value={person.email} |
| 168 | + onChange={e => setPerson({ ...person, email: e.target.value })} |
| 169 | + /> |
| 170 | + </label> |
| 171 | + </div> |
| 172 | + <div style={{ marginTop: 8 }}> |
| 173 | + <button onClick={savePerson}>Save</button> |
| 174 | + </div> |
| 175 | + </div> |
| 176 | + ); |
| 177 | +} |
| 178 | + |
| 179 | +export const component: IComponent = { |
| 180 | + async loaded(componentContext, args: { documentId: string; }) { |
| 181 | + const studioPro = getStudioProApi(componentContext); |
| 182 | + createRoot(document.getElementById("root")!).render( |
| 183 | + <StrictMode> |
| 184 | + <PersonEditor studioPro={studioPro} documentId={args.documentId} /> |
| 185 | + </StrictMode> |
| 186 | + ); |
| 187 | + } |
| 188 | +}; |
| 189 | +``` |
| 190 | + |
| 191 | +Lastly, we need to update the build instructions and manifest. To do so, replace the contents of `build-extension.mjs` with |
| 192 | + |
| 193 | +```javascript {hl_lines=["16-19"]} |
| 194 | +import * as esbuild from 'esbuild' |
| 195 | +import {copyToAppPlugin, copyManifestPlugin, commonConfig} from "./build.helpers.mjs" |
| 196 | +import parseArgs from "minimist" |
| 197 | + |
| 198 | +const outDir = `dist/myextension` |
| 199 | +const appDir = "/Users/petar.vukmirovic/Mendix/App-Guide-Test-Blob" |
| 200 | +const extensionDirectoryName = "extensions" |
| 201 | + |
| 202 | +const entryPoints = [ |
| 203 | + { |
| 204 | + in: 'src/main/index.ts', |
| 205 | + out: 'main' |
| 206 | + } |
| 207 | +] |
| 208 | + |
| 209 | +entryPoints.push({ |
| 210 | + in: 'src/ui/editor.tsx', |
| 211 | + out: 'editor' |
| 212 | +}) |
| 213 | + |
| 214 | +const args = parseArgs(process.argv.slice(2)) |
| 215 | +const buildContext = await esbuild.context({ |
| 216 | + ...commonConfig, |
| 217 | + outdir: outDir, |
| 218 | + plugins: [copyManifestPlugin(outDir), copyToAppPlugin(appDir, outDir, extensionDirectoryName)], |
| 219 | + entryPoints |
| 220 | +}) |
| 221 | + |
| 222 | +if('watch' in args) { |
| 223 | + await buildContext.watch(); |
| 224 | +} |
| 225 | +else { |
| 226 | + await buildContext.rebuild(); |
| 227 | + await buildContext.dispose(); |
| 228 | +} |
| 229 | +``` |
| 230 | + |
| 231 | +and the contents of `manifest.json` with |
| 232 | + |
| 233 | +```json {hl_lines=["6"]} |
| 234 | +{ |
| 235 | + "mendixComponent": { |
| 236 | + "entryPoints": { |
| 237 | + "main": "main.js", |
| 238 | + "ui": { |
| 239 | + "editor": "editor.js" |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +### Walking through the example code |
| 247 | + |
| 248 | +In `src/main/index.ts`, we are first registering a new document type. When a new document type is registered, you can perform all |
| 249 | +CRUD operations on this document type, but it will not yet be shown in the UI since no editor for it has yet been registered. Note |
| 250 | +that you can optionally provide `readableTypeName` which will be shown instead of full type name in every user-facing context such |
| 251 | +as in logs and in the Studio Pro UI. You can optionally customize the (de)serialization of the document contents to string, which |
| 252 | +defaults to `JSON.stringify` and `JSON.parse`. The next call to `studioPro` API registers editor for our document type. It registers |
| 253 | +`editor` entry point of our extension as the editor that will be shown when user interacts with the document in StudioPro (for example |
| 254 | +through App Explorer or Find Results). This editor will be shown as a tab, but you can also register editors to be shown as |
| 255 | +modal dialogs. Lastly, icons for both light and dark theme are registered. Those icons will be shown in all UI elements where |
| 256 | +document icon is needed. |
| 257 | + |
| 258 | +The first highlighted block of code in `src/ui/editor.tsx` listens for changes in documents to make sure that the last version of currently |
| 259 | +active document is shown. Note that the document can be changed outside currently open editor by calls to custom blob document API, |
| 260 | +as well as by some Studio Pro operations such as undoing changes. In the next highlighted block, we fetch the document contents |
| 261 | +whenever new document is open or updated. Lastly, we make sure that the current changes can be saved. |
| 262 | + |
| 263 | +We also highlighted changes that need to be made to `build-extension.mjs` and `manifest.json` to make sure that `editor` entry point |
| 264 | +is properly built and loaded. |
| 265 | + |
| 266 | +## Extensibility Feedback |
| 267 | + |
| 268 | +If you would like to provide us with additional feedback, you can complete a small [survey](https://survey.alchemer.eu/s3/90801191/Extensibility-Feedback). |
| 269 | + |
| 270 | +Any feedback is appreciated. |
0 commit comments