Skip to content
Colin Wren
Twitter

My experiences building a plugin of my app for Miro

JavaScript, Software Development, Technology8 min read

Example of the Reciprocal.dev miro plugin showing loading a version into a frame and activating test coverage & user journey visualisations
Example of the Reciprocal.dev miro plugin showing loading a version into a frame and activating test coverage & user journey visualisations (sequence shorted to reduce GIF size)

After launching the Reciprocal.dev alpha a couple of months ago we started to think about how we’d better scale out our data-driven User Journey Mapping functionality and bring it to the tools that teams use everyday.

We decided Miro would be a good platform to run an experiment to validate this idea due to it being the tool that most companies we’ve interacted with adopted when the lockdowns in the UK happened.

It also worked in Miro’s favour that the inspiration for what would eventually become Reciprocal.dev, which started from me experimenting with building a Miro plugin to link acceptance criteria against UI elements in November 2020.

In order to conduct the experiment we agreed on the following scope:

  • We’d build a plugin for Miro that users would install via the Miro Marketplace
  • The plugin would allow users to tag widgets on the board with development data points that would then be used to visualise that information
  • The plugin would allow users to save versions of a User Journey Map and load versions at a later point to compare them

This would put the functionality on-par with the web app that we built for the alpha, but without us having full control over every aspect of the UI.

The upside of building a Miro plugin would be that we’d no longer need to invest time in building our own editor which would speed up our ability to ship new data driven features.

The downside of building a Miro plugin was that where we didn’t have full control, we were exposing ourselves to the risks of Miro changing their platform and rules, as well as the more limited interaction options presented by the Miro API.

We felt that the upsides outweighed the downsides for the purpose of our experiment so I cracked on building an MVP of a Reciprocal.dev Miro plugin.

A quick intro to building a Miro plugin

A Miro plugin is essentially a set of HTML & JavaScript files rendered in iframes that use the Miro SDK (and maybe the REST API too) to interact with the Miro UI, the Miro board and the board’s widgets.

Before you can start building a Miro plugin you must first set up a ‘developer team’ and create an app in your Miro settings screen and then install that app into a board.

Once you’ve set up and installed your app you’ll need to provide the actual plugin application behind it. Miro provides a really great helper library to bootstrap a plugin called create-miro-app which I’d highly recommend using to do this.

Within the plugin you’ll be able use the Miro SDK which exposes a set of methods for creating, reading, updating and deleting widgets on the current board as well as creating extension points to register your UI with.

In order for the user to launch your plugin you’ll need to register an extension point in the Miro UI. This is essentially a button that when pressed will trigger a callback that you can use to show your plugin in a ‘custom view’ which can be a modal, left sidebar or bottom panel.

The ‘custom view’ is an iframe that loads a HTML from within your plugin and you can have multiple HTML files that can be loaded if you want to offer different layouts for different extension points.

Inside of the ‘custom view’ HTML you’ll want to load JavaScript modules in so that the user can use your UI to interact with Miro via the SDK.

The Miro SDK

The Miro SDK offers a means of interacting with widgets as well as a way to listen to events happening within Miro such as when selections are made on the board.

Each widget in Miro has it’s own ID and you’ll need to use that ID to interact with the widget so having an event listener for what’s been selected on the board and storing the IDs in state is a good way of ensuring your plugin refreshes when the user selects something else without the need to read the selection everytime an action needs to happen.

1import React, { useEffect, useState } from 'react'
2
3function App() {
4
5 const [selection, setSelection] = useState([])
6 const [isLoading, setIsLoading] = useState(true)
7
8 async function setInitialSelection() {
9 const selection = await miro.board.selection.get()
10 setSelection(selection)
11 }
12
13 function handleSelection({ data }) {
14 setSelection(data)
15 }
16
17 useEffect(() => {
18 miro.onReady(async () => {
19 miro.addListener('SELECTION_UPDATED', handleSelection)
20 await setInitialSelection()
21 setIsLoading(false)
22 })
23
24 return function cleanUp() {
25 miro.removeListener('SELECTION_UPDATED', handleSelection)
26 }
27 })
28
29 return {
30 // rest of app
31 }
32}
After setting up the selection event listener a call to set the initial selection will ensure that the current selection is accounted for if the user opens your plugin after selecting a widget

If you have the ID of one or more widgets that you want to manipulate you can then use the following commands to do CRUD based operations on them.

  • miro.board.widgets.get() — This takes a lodash filter object that will return all results that match the predicate, this does not support the function option that lodash supports so you can’t do anything complex
  • miro.board.widgets.create() — This creates a new widget on the board using the object passed in to set the attributes, the minimum the object can be is the type of widget being created (i.e. {"type": "CARD"} )
  • miro.board.widgets.update() — This takes a list of widgets (such as returned by the widgets.get() method) and updates them based on the new values that have been set via a mutable operation (i.e. widget.text = 'New Value' )
  • miro.board.widgets.deleteById() — This takes the ID of a widget to delete

There are some limitations on what type of widget you can interact with. Currently you can use miro.board.widgets.get() to read any type of widget but widgets.create() and widgets.update() only support the following types (at time of writing):

  • STICKER — Sticky note widgets
  • TEXT — Text widgets
  • SHAPE — Shape widgets although this type is used as a base for a lot of other widget types so I can’t say if those more complex types are supported
  • CARD — Card widgets are similar to shapes, I can’t say if the cards used by the Jira integration are supported
  • LINE — Lines and connections between widgets
  • FRAME — Limited to updates only

If like the Reciprocal.dev plugin you’re looking to support any widget the user might want to use you’ll have to find a means of providing interaction to the unsupported widget types via creating and updating supported widget types.

1async function updateWidgets(widgetIds) {
2 const widgetObjects = await Promise.all(widgetsIds.map(async (widgetId) => {
3 const widgets = await miro.board.widgets.get({ id: widgetId })
4 if (widgets.length > 0) {
5 return widgets[0]
6 }
7 return null
8 }))
9 const widgets = widgetObjects.filter(x => x !== null)
10 widgets.forEach((widget) => {
11 switch(widget.type) {
12 case 'TEXT':
13 return widget.style.textColor = '#d5f692'
14 case 'LINE':
15 return widget.style.lineColor = '#8fd14f'
16 case 'STICKER':
17 return widget.style.stickerBackgroundColor = '#d5f692'
18 default:
19 return widget.style.backgroundColor = '#d5f692'
20 }
21 })
22 try {
23 await miro.board.widgets.update(widgets)
24 } catch (err) {
25 console.error('Error updating widgets', err)
26 }
27}
This function reads the widgets for the defined widget IDs and updates their colours. The try / catch is needed as the SDK will throw an error if the widgets is of an unsupported type

The Miro REST API

The Miro REST API offers a means of programmatically updating boards, users and widgets provided that the access token it’s being called with has the correct scopes.

In order to get an access token for the API you’ll first need to ensure that the user has authorised the app to act on their behalf via the OAuth2 flow and that you extract the access token during the last stage.

The REST API has a similar set of CRUD operations that can be called and it is also limited to the same type of widgets as the SDK, however the API is a lot more strict in the values it accepts when updating widgets (covered in the gotcha section later).

In order to work with widgets via the REST API you need to pass in the board ID as well as the widget ID. You can find the board ID in the URL or calling miro.board.info.get() with the Miro SDK.

Communicating state across your plugin’s UI

Due to the way that your plugin is loaded into Miro via iframes there’s no global state available to use between your different ‘custom views’ so you need to find ways of sharing data and setting default states.

I found three ways that made this easier for me:

Pass default values in as query strings

In the Reciprocal.dev plugin I added a form to allow users to create a new User Journey entity that could be linked to a widget on the board but I had no means of ‘loading’ that User Journey’s data if the user wanted to edit the entity.

In order to ‘load’ the data I amended the code that launched the HTML for the form in a modal to include the entity’s data as query strings in the URL.

1// List item to launch modal to add or edit items
2function ListItem({ item }) {
3
4 function onEdit(event) {
5 event.preventDefault()
6 event.stopPropagation()
7 miro.board.ui.openModal(`./item-form.html?itemId=${item.id}&name=${item.name}`)
8 }
9
10 return (
11 <button key={item.id} onClick={onEdit}>{item.name}</button>
12 )
13}
14
15
16function ItemList({ items }) {
17 const itemList = items.map((item) => <ListItem item={item} />)
18
19 function onAdd(event) {
20 event.preventDefault()
21 miro.board.ui.openModal('./item.form.html')
22 }
23
24 return (
25 <div>
26 <button onClick={onAdd}>Create Item</button>
27 {itemList}
28 </div>
29 )
30}
31
32
33// View to show in modal = item-form.html
34
35function getItemFromUrlParams() {
36 const search = window.location.search
37 const params = new URLSearchParams(search)
38 const itemId = params.get('itemId')
39 const name = params.get('name')
40 return {
41 id: itemId,
42 name: name !== null ? name : '',
43 }
44}
45
46function ItemForm() {
47 const item = getItemFromUrlParams()
48 const [nameValue, setNameValue] = useState(item.name)
49 const isEditMode = item.id !== null
50 // rest of the form
51}
By checking the params when the custom view is loaded the same form can be used to create or edit objects

Use broadcastData to send and listen to specific updates

The Miro SDK provides a means for custom views to broadcast update events and other custom views to listen for these updates.

In the Reciprocal.dev plugin I added a call to miro.broadcastData() when the User Journey create/edit form is submitted in its modal custom view and set up an event listener within the left sidebar custom view to listen for the DATA_BROADCASTED event.

As I’m using a reducer within the React app that powers my plugin I simply sent my actions through broadcastData() and used the DATA_BROADCASTED event callback to match the action type against a set of supported types to pass to the reducer.

1// createItem action
2function createItem(name) {
3 return {
4 type: 'CREATE_ITEM',
5 data: {
6 name
7 }
8 }
9}
10
11// In item form the submit button will send data and close the modal the form is displayed in
12function ItemForm() {
13 const [namvValue, setNameValue] = useState('')
14
15 function onSubmit(event) {
16 event.preventDefault()
17 const action = createItem(nameValue)
18 miro.board.ui.closeModal()
19 }
20 // Rest of the form to set the name value
21}
22
23// In the item list custom view the broadcast event will be consumed and actioned
24function ItemList() {
25 const { state, dispatch } = useContext(AppContext) // React app context with reducer
26
27 function handleBroadcastData(event) {
28 const action = event.data
29 if (['CREATE_ITEM', 'EDIT_ITEM'].includes(action.type)) {
30 dispatch(action)
31 }
32 }
33
34 useEffect(() => {
35 miro.onReady(async () => {
36 miro.addListener('DATA_BROADCASTED', handleBroadcastData)
37 })
38
39 return function cleanUp() {
40 miro.removeListener('DATA_BROADCASTED', handleBroadcastData)
41 }
42 })
43
44 // Render list based on state from reducer
45}
The event listener receives a ‘data’ object which contains the object sent via miro.broadcastData(). I filter out any events the receiving component won’t process as I have multiple custom views that could be open in my plugin

Serialise & Deserialise app state to localstorage

I used this approach to manage the UI state as I have a number of extention points that I need to play nicely with each other.

When my extension points are triggered I write a flag to local storage so that subsequent clicks on other extension points and can read that flag and act accordingly. This prevents them closing UI elements that are already opened that the extention point would open anyway.

1// In index.js where plugin is initilised
2localStorage.setItem('BOTTOM_BAR', 'CLOSED')
3localStorage.setItem('SIDEBAR', 'NONE')
4
5miro.onReady(() => {
6 miro.initialize({
7 extensionPoints: {
8 toolbar: {
9 title: 'Plugin',
10 async onClick() {
11 const sidebarTool = localStorage.getItem('SIDEBAR')
12 if (sidebarTool !== 'SELECTOR') {
13 localStorage.setItem('SIDEBAR', 'SELECTOR')
14 await miro.board.ui.openLeftSidebar('app.html')
15 } else {
16 localStorage.setItem('SIDEBAR', 'NONE')
17 miro.board.ui.closeLeftSidebar()
18 }
19 }
20 },
21 bottomBar: {
22 title: 'Plugin',
23 async onClick() {
24 const bottomBarState = localStorage.getItem('BOTTOM_BAR')
25 if (bottomBarState === 'CLOSED') {
26 localStorage.setItem('BOTTOM_BAR', 'OPEN')
27 await miro.board.ui.openBottomPanel('bottom-panel.html')
28 } else {
29 localStorage.setItem('BOTTOM_BAR', 'CLOSED')
30 miro.board.ui.closeBottomPanel()
31
32 }
33 }
34 }
35 }
36 })
37})
38
39// In bottom-panel.html where buttons there can open left sidebar
40function BottomPanel() {
41
42 async function onClick(event) {
43 event.preventDefault()
44 const leftSidebarState = localStorage.getItem('SIDEBAR')
45 if (leftSidebarState !== 'DATA') {
46 localStorage.setItem('SIDEBAR', 'DATA')
47 await miro.board.ui.openLeftSidebar('item-list.html')
48 } else {
49 localStorage.setItem('SIDEBAR', 'NONE')
50 miro.board.ui.closeLeftSidebar()
51 }
52 }
53}
I used the localStorage as a means of storing which UI elements were active so clicking on extension points for views that were already open didn’t close them instead of showing the intended tool

Inconsistencies between API & SDK and gotchas

While working on the Reciprocal.dev plugin I struggled to work with the FRAME widget type using the Miro SDK as while it could update the frame I was looking to interact with it could not create widgets within the frame.

I ended up using the REST API for this as it supported passing in a parentFrameId attribute when creating a widget but when this was supplied the positioning attributes were there ignored.

This unforunately was only the start of the differences that I found so I’m going to list some of the major ones and the solutions I added to bypass them.

Creating a widget within a frame

As mentioned previously the SDK doesn’t offer a means of adding a widget to a frame and the REST API will do this but ignore the positioning. My solution to this was to create the widget under the frame and then update the widget’s position.

For the MVP this is fine enough but this could be improved by setting the widget to not be visible on creation and then setting it to be visible after the position update so it appears to be created in place.

1import requests
2
3BOARD_ID = ''
4AUTH_TOKEN = ''
5URL = 'https://api.miro.com/v1/boards/{board_id}/widgets'.format(board_id=BOARD_ID)
6FRAME_ID = ''
7
8headers = {
9 'Accept': 'application/json',
10 'Content-Type': 'application/json',
11 'Authorization': 'Bearer {token}'.format(token=AUTH_TOKEN)
12}
13
14STICKERS = [
15 {
16 "text": "Sticker",
17 "x" 70,
18 "y": 190,
19 "style": {
20 "backgroundColor": "#b384bb"
21 }
22 }
23]
24
25def get_frame(frame_id):
26 widget_url = widget_url = '{base}/{id}'.format(base=URL, id=frame_id)
27 try:
28 response = requests.request('GET', widget_url, headers=headers)
29 return response.json()
30 except Exception:
31 print('Failed to get frame widget')
32 return False
33
34def create_stickers(frame, stickers):
35 created_stickers = []
36 for sticker in stickers:
37 style = sticker.get('style', {})
38 data = {
39 'type': 'sticker',
40 'text': sticker.get('text', ''),
41 'parentFrameId': frame.get('id'),
42 'scale': sticker.get('scale', 0.54),
43 'height': sticker.get('height', 228),
44 'width': sticker.get('width', 199),
45 'style': {
46 'backgroundColor': style.get('backgroundColor', '#fff9b1'),
47 'fontFamily': style.get('fontFamily', 'OpenSans'),
48 'fontSize': style.get('fontSize', 14),
49 'textAlign': style.get('textAlign', 'center'),
50 'textAlignVertical': style.get('textAlignVertical', 'middle')
51 }
52 }
53 try:
54 response = requests.request('POST', URL, headers=headers, json=data)
55 widget_data = response.json()
56 if widget_data.get('status', False):
57 print(widget_data)
58 raise Exception(widget_data.get('message', 'Unable to create widget'))
59 created_stickers.append({
60 'id': widget_data.get('id'),
61 'type': 'STICKER',
62 'text': widget_data.get('text', 'Error'),
63 'x': sticker.get('x', 120),
64 'y': sticker.get('y', 120
65 'style': widget_data.get('style')
66 })
67 except Exception as e:
68 print('Failed to create sticker {}'.format(sticker.get('text')))
69 return created_stickers
70
71def update_widget_positions(frame, widgets):
72 frameX = frame.get('x', 0) - (frame.get('width', 2) / 2)
73 frameY = frame.get('y', 0) - (frame.get('height', 2) / 2)
74 for index, widget in enumerate(widgets):
75 widget_url = '{base}/{id}'.format(base=URL, id=widget.get('id'))
76 data = {
77 'x': frameX + widget.get('x', 120),
78 'y': frameY + widget.get('y', 120)
79 }
80 try:
81 response = requests.request('PATCH', widget_url, headers=headers, json=data)
82 widget_data = response.json()
83 if widget_data.get('status', False):
84 raise Exception(widget_data.get('message', 'Unable to move widget'))
85 except Exception as e:
86 print('Failed to update widget position {}'.format(widget.get('id', '')))
87
88def create_widgets_in_frame():
89 frame = get_frame(FRAME_ID)
90 if frame:
91 created_stickers = create_stickers(frame, STICKERS)
92 update_widget_positions(frame, created_stickers)
The ‘parentFrameId’ attribute will create the widget inside of the frame but it will be placed in the middle of the frame, updating the position values after widget creation will move it into it’s intended place in the frame

Inconsistencies in widget style properties

Part of the functionality of the Reciprocal.dev plugin is being able to select a frame and save it’s (supported widget) content as a ‘version’ so it can be loaded into a frame later (the Miro version of switching version in the Reciprocal.dev web app).

In order to perfectly reproduce the saved frame’s content when it’s loaded the REST API needs to be provided with the style properties of the widget but the REST API is much stricter on what values are allowed compared to the Miro SDK.

An example of this strictness would be with the opacity value for a SHAPE widget’s background colour. The SDK allows any number between 0 and 1 but the REST API only accepts 0 , 0.25 , 0.5 , 0.75 or 1 so I had to create a transformer to resolve the SDK value to a value accepted by the REST API.

There are also completely different values used between the SDK and the REST API, for example the SDK would hold the shapeType style property for the SHAPE widget type as a number but the REST API requires a string.

I ended up writing a set of functions to transform SDK styles into those compatible with the REST API. These map the SDK values to the API ones or where the API is stricter it places the SDK value into one of the set values.

1export const colours =[
2 '#f5f6f8', // grey
3 '#fff9b1', // yellow
4 '#f5d128', // dark orange
5 '#d0e17a', // light green
6 '#d5f692', // lighter green
7 '#a6ccf5', // light blue
8 '#67c6c0', // torquise
9 '#23bfe7', // blue
10 '#ff9d48', // orange
11 '#ea94bb', // rose
12 '#f16c7f', // red
13 '#b384bb', // purple
14 'transparent', // transparent (non-sticker only)
15]
16
17export const fonts = [
18 "Arial",
19 "Abril Fatface",
20 "Bangers",
21 "EB Garamond",
22 "Georgia",
23 "Graduate",
24 "Gravitas One",
25 "Fredoka One",
26 "Nixie One",
27 "OpenSans",
28 "Permanent Marker",
29 "PT Sans",
30 "PT Sans Narrow",
31 "PT Serif",
32 "Rammetto One",
33 "Roboto",
34 "Roboto Condensed",
35 "Roboto Slab",
36 "Caveat",
37 "Times New Roman",
38 "Titan One",
39 "Lemon Tuesday",
40 "Roboto Mono",
41 "Noto Sans",
42 "IBM Plex Sans",
43 "IBM Plex Serif",
44 "IBM Plex Mono"
45]
46
47export const shapeFonts = {
48 '0': 'Arial',
49 '2': 'Abril Fatface',
50 '3': 'Bangers',
51 '4': 'EB Garamond',
52 '5': 'Georgia',
53 '6': 'Graduate',
54 '7': 'Gravitas One',
55 '8': 'Fredoka One',
56 '9': 'Nixie One',
57 '10': 'OpenSans',
58 '11': 'Permanent Marker',
59 '12': 'PT Sans',
60 '13': 'PT Sans Narrow',
61 '14': 'PT Serif',
62 '15':'Rammetto One',
63 '16': 'Roboto',
64 '17': 'Roboto Condensed',
65 '18': 'Roboto Slab',
66 '19': 'Caveat',
67 '20': 'Times New Roman',
68 '21': 'Titan One',
69 '22': 'Lemon Tuesday',
70 '23': 'Roboto Mono',
71 '24': 'Noto Sans',
72 '25': 'IBM Plex Sans',
73 '26': 'IBM Plex Serif',
74 '27': 'IBM Plex Mono',
75 '28': 'Spoof',
76 '29': 'Tiempos Text'
77}
78
79export const borderStyles = {
80 '2': 'normal'
81 '1': 'dashed'
82 '0': 'dotted'
83}
84
85export const shapeTypes = {
86 '3': "rectangle",
87 '4': "circle",
88 '5': "triangle",
89 '7': "rounded_rectangle",
90 '8': "rhombus",
91 '6': "callout",
92 '10': "parallelogram",
93 '11': "star",
94 '12': "arrow",
95 '13': "arrow_left",
96 '16': "pentagon",
97 '17': "hexagon",
98 '18': "octagon",
99 '19': "trapeze",
100 '20': "predefined_process",
101 '21': "arrow_left_right",
102 '22': "cloud",
103 '23': "brace_left",
104 '24': "brace_right",
105 '25': "cross",
106 '26': "barrel"
107}
108
109export const textAlign = {
110 l: "left",
111 c: "center",
112 r: "right",
113}
114
115export const verticalTextAlign = {
116 t: "top",
117 m: "middle",
118 b: "bottom"
119}
120
121export const lineTypes = {
122 '0': 'straight',
123 '1': 'orthogonal',
124 '2': 'bezier',
125 '3': 'sketch'
126}
127
128export const endTypes = {
129 '0': "none",
130 '1': "opaque_block",
131 '2': "rhombus",
132 '3': "opaque_rhombus",
133 '4': "circle",
134 '5': "opaque_circle",
135 '6': "block",
136 '7': "open_arrow",
137 '8': "opaque_arrow"
138}
139
140function mapOpacity(opacity) {
141 if (opacity >= 1) {
142 return 1
143 } else if (opacity < 1 && opacity >= 0.75) {
144 return 0.75
145 } else if (opacity < 0.75 && opacity >= 0.5) {
146 return 0.5
147 } else if (opacity < 0.5 && opacity >= 0.25) {
148 return 0.25
149 } else {
150 return 0
151 }
152}
153
154function mapBorderWidth(width) {
155 if (width >= 24) {
156 return 24
157 } else if (width < 24 && width >= 16) {
158 return 16
159 } else if (width < 16 && width >= 8) {
160 return 8
161 } else if (width < 8 && width >= 4) {
162 return 4
163 } else {
164 return 2
165 }
166}
167
168 function mapLineThickness(width) {
169 if (width >= 24) {
170 return 24
171 } else if(width < 24 && width >= 20) {
172 return 20
173 } else if (width < 20 && width >= 16) {
174 return 16
175 } else if (width < 16 && width >= 12) {
176 return 12
177 } else if (width < 16 && width >= 8) {
178 return 8
179 } else if (width < 8 && width >= 5) {
180 return 5
181 } else if (width < 5 && width >= 4) {
182 return 4
183 } else if (width < 4 && width >= 3) {
184 return 3
185 } else if (width < 3 && width >= 2) {
186 return 2
187 } else {
188 return 1
189 }
190}
191
192function mapColour(colour) {
193 if (colours.includes(colour)) {
194 return colour
195 }
196 switch(colour) {
197 case '#c9df56': // Map yellow green to light green
198 return '#d5f692'
199 case '#93d275':
200 return '#d0e17a' // Map green to light green
201 case '#6cd8fa':
202 return '#a6ccf5' // Map azure to light blue
203 case '#7b92ff':
204 return '#23bfe7' // Map blue to API blue
205 case '#be88c7':
206 return '#b384bb' // Map purple to API purple
207 default:
208 return '#fff9b1' // Map anything else to yellow
209 }
210}
211
212function getBorderOpacity(opacity, color, width) {
213 if (color === 'transparent' || width === 0) {
214 return 0.0
215 }
216 return mapOpacity(opacity)
217}
218
219export function normaliseStickerStyle(style) {
220 return {
221 backgroundColor: mapColour(style.stickerBackgroundColor),
222 fontFamily: 'OpenSans', // Turns out the Miro API lies and only supports this font!
223 fontSize: style.fontSize,
224 textAlign: textAlign[style.textAlign],
225 textAlignVertical: verticalTextAlign[style.textAlignVertical]
226 }
227}
228
229export function normaliseShapeStyle(style) {
230 return {
231 backgroundColor: style.backgroundColor !== 'transparent' ? style.backgroundColor : '#000000',
232 backgroundOpacity: style.backgroundColor !== 'transparent' ? mapOpacity(style.backgroundOpacity) : 0.0,
233 borderColor: style.borderColor !== 'transparent' ? style.borderColor : '#000000',
234 borderOpacity: getBorderOpacity(style.borderOpacity, style.borderColor, style.borderWidth),
235 borderStyle: borderStyles[`${style.borderStyle}`],
236 borderWidth: mapBorderWidth(style.borderWidth),
237 fontFamily: shapeFonts[`${style.fontFamily}`],
238 fontSize: style.fontSize,
239 shapeType: shapeTypes[`${style.shapeType}`],
240 textAlign: textAlign[style.textAlign],
241 textAlignVertical: verticalTextAlign[style.textAlignVertical],
242 textColor: style.textColor
243 }
244}
245
246export function normaliseTextStyle(style, scale) {
247 return {
248 backgroundColor: style.backgroundColor !== 'transparent' ? style.backgroundColor : '#000000',
249 backgroundOpacity: style.backgroundColor !== 'transparent' ? mapOpacity(style.backgroundOpacity) : 0.0,
250 fontFamily: shapeFonts[`${style.fontFamily}`],
251 fontSize: ((14 * scale) | 0),
252 textAlign: textAlign[style.textAlign],
253 textColor: style.textColor
254 }
255}
256
257export function normaliseLineStyle(style) {
258 return {
259 borderColor: style.lineColor,
260 borderStyle: borderStyles[`${style.lineStyle}`],
261 borderWidth: mapLineThickness(style.lineThickness),
262 lineEndType: endTypes[`${style.lineEndStyle}`],
263 lineStartType: endTypes[`${style.lineStartStyle}`],
264 lineType: lineTypes[`${style.lineType}`]
265 }
266}
267
268export function normaliseCardStyle(style) {
269 return {
270 backgroundColor: style.backgroundColor
271 }
272}
There’s a lot of values that need to mapped from the Miro SDK for them to work with the REST API

Widget type strings

This one is a small one but the SDK uses uppercase widget type strings (e.g SHAPE ) but the REST API uses lowercase widget type strings (e.g. shape ).

Summary

Overall the experience of building a Miro plugin has been a good problem to solve and it’s been really great seeing Reciprocal.dev’s functionality ported to Miro as that was what started the journey just over a year ago and it’s come full circle.

If I had one wish in making the developer experience easier I think it would be to increase the support for the other types of widget available within the SDK/API as the lack of support puts developers in a situation of having to explain to their end-user that their tool has limited functionality but that limitation isn’t something in their control.

Based on the roadmap shared by Miro at their most recent conference it didn’t look like there was going to be much development in the area with them instead focusing on chat integration and enterprise functionality but maybe at some point the full set of widges will be supported.