NAV

Welcome

Welcome to the Lucid developer platform! Lucid provides powerful APIs that enable developers to add the power of visual collaboration to their product or workflow. This section serves as an introduction to what's possible and how to start building.

Use cases

Developers can build multiple kinds of apps on our platform. Below are examples of how you can use our APIs:

  • External embed of Lucid documents: Display diagrams and whiteboards created with Lucid in other websites.
  • On-canvas link unfurl: Visualize content from other platforms within Lucid products.
  • Task management cards: Provide a way to visually create and track tasks across the entire project workflow.
  • Shape libraries: Distribute custom icons and shapes across an organization or globally on the Lucid marketplace.
  • Data import/syncing: Update Lucid diagrams with data from outside sources and make changes to data sources directly from Lucid documents.

APIs

Lucid offers three sets of APIs:

  • Lucid's REST API allows developers to programmatically interact ( create, search, read contents of, trash, etc.) documents and folders. This API also provides endpoints for embedding documents, adding/removing collaborators, transfering content between users, and more.
  • Lucid's Extension API allows developers to add functionality to Lucid editors. This API can import data, add/read/modify shapes and lines on the canvas, and define new shape libraries for internal or public distribution.
  • Lucid's SCIM API offers user provisioning and group management, available for admins on an Enterprise account.

Start building

Building on the developer platform requires access to developer tools. To get access, read Unlocking developer tools.

To start building on the REST API, go to the OAuth 2.0 client creation guide for help creating a client, then visit the Using OAuth 2.0 section in our REST API technical documentation to create an access token needed for API requests.

To start building on the Extension API, see Getting started in our Extension API technical documentation. For examples of extensions built on the Extension API, see Lucid's public GitHub repository.

To start building on the SCIM API, see our SCIM technical documentation.

Partner with us

Looking to build apps and integrations on Lucid's platform? Sign up to be a technology partner! We'd love to learn more about how you use Lucid and take our partnership to the next level. Partnership benefits may include:

  • Co-marketing support
  • Sales and support enablement
  • Listing and premium placement on the Lucid integrations marketplace
  • Collaboration with Lucid’s product team
  • And much more

Get support

If you have questions or need support, please contact us in our Lucid developer forum.

Request feature

Submit feedback and feature requests through our feedback form.

Unlocking developer tools

What are developer tools?

Lucid provides developer tools that allow you to create, test, and distribute apps built on Lucid’s REST and Extension APIs:

  1. The developer portal allows you to create new applications. An application contains extension packages built using Lucid’s Extension API and/or an OAuth 2.0 client needed to use Lucid’s REST API. The portal is also where you distribute apps.

Step 1

  1. The developer menu allows you to test apps built on Lucid's Extension API.

Step 2

Users without these tools are unable to build or distribute code with these APIs.

How do I unlock developer tools?

There are two ways to unlock developer tools:

  1. Via User Setting: Check the “Enable developer tools” checkbox in user settings. Please note the checkbox may be disabled due to account settings controlled by your Lucid Admin.
  2. Via your Lucid Admin: If the “Enable developer tools” checkbox is disabled, ask your Lucid Admin to assign you the developer role.

Via User Setting

The “Enable developer tools" checkbox is found in user settings (see GIF below). You can find user settings in your account settings (accessible in the upper right of lucid.app). Please note that admins are able to enable/disable your ability to unlock developer tools in user settings (instructions to do so here).

User Settings

Via Admin

Lucid admins are able to assign a user the developer role (regardless of whether the “Enable developer tools" checkbox is enabled or disabled). Assigning a user the developer role unlocks developer tools for that user. Admins can assign a user the developer role by navigating to the users section of the admin settings, selecting a user, and editing their roles.

Note that an admin must be an account owner or team admin to be capable of assigning the developer role (see admin roles for more info).
Also note that assigning a user the developer role unlocks developer tools for that user, even if they haven’t enabled developer features in their user settings.

Admin-assigned Role

Additional Resources

Application Collaborator Roles

What is an application collaborator role?

Application collaborator roles enable you to manage which other Lucid users can test, develop, publish, and maintain your app.

At a high-level, the purpose of each role is:

  • App owners can be considered an "app admin". App owners have full permissions to the app, including viewing the app's OAuth 2.0 client secret or publishing the app for the first time.
  • The code editor role is designed for the developers actually coding the app. They can upload new versions of the app to the developer portal and view the client ID and redirect URLs.
  • The listing editor role is designed for marketing your app on the Lucid Marketplace. Listing editors can edit the marketplace listing page's content and use any uploaded version of the app to collect screenshots if needed. However, listing editors don't have development-related permissions: they can neither upload package versions nor see the OAuth 2.0 client ID or secret.
  • The tester role is designed for those testing your app. Or if you don't intend to publish your app, this role can also be used to distribute your app to select users. The only permission a tester has is to use the app before it's published.

Who can I invite to collaborate?

In order to preserve the security and integrity of your app and its marketplace listing, app owners, code editors, and listing editors need to be part of your company's Lucid account. However, to facilitate testing unreleased versions of your app with users from other organizations, you can invite any Lucid user to be a tester on your app.

Summary of application collaborator roles

App owner

  • Create, view, and manage OAuth 2.0 credentials.
  • Request app be listed in Lucid marketplace.
  • Request app be listed in the company-only marketplace.
  • Upload new versions of the app.
  • Choose the app version published to the marketplace.
  • View the app's marketplace listing.
  • Edit the app's marketplace listing.
  • Use any uploaded app version before it's published.
  • Invite/remove collaborators at the same company with the following roles: app owner, code editor, listing editor, tester.
  • Invite/remove collaborators at a different company with the following role: tester.
  • Automatically get access to both the developer portal and the developer menu unless an account admin has disabled self-selecting into the developer role.*

Code editor

  • View OAuth 2.0 client ID and redirect URLs.
  • Upload new versions of the app.
  • Choose the app version published to the marketplace (after the app has been initially published).
  • View the app's marketplace listing.
  • Edit the app's marketplace listing.
  • Use any uploaded app version before it's published.
  • Invite/remove collaborators at the same company with the following roles: code editor, listing editor, tester.
  • Invite/remove collaborators at a different company with the following role: tester.
  • Automatically get access to both the developer portal and the developer menu unless an account admin has disabled self-selecting into the developer role.*

Listing editor

  • View the app's marketplace listing.
  • Edit the app's marketplace listing.
  • Use any uploaded app version before it's published.
  • Invite/remove collaborators at the same company with the following role: listing editor.
  • Automatically get access to the developer portal unless an account admin has disabled self-selecting into the developer role.*

Tester

  • View the app's marketplace listing.
  • Use any uploaded app version before it's published.
  • Automatically get access to the developer portal unless an account admin has disabled self-selecting into the developer role.*

User not added as collaborator

  • View the app's marketplace listing after the app has been approved to be in the marketplace.
  • Install the app's published version after the app has been approved to be in the marketplace.

What can application collaborator roles do?

Application collaborator roles are most useful for applying the principle of least privilege. This principle is that a user should only be given the minimum privileges necessary to perform their required tasks, minimizing the potential for unauthorized access or unintended actions.

For example, these roles can allow quality assurance testing by giving users the "Tester" role. Or, an admin can give marketing personnel limited access to edit the marketplace listing by assigning the "Listing editor" role.

Note that users on different Lucid accounts can be assigned the "Tester" role by anyone with "App owner" or "Code editor" roles.

How do I assign application collaborator roles?

Note that if you already created an application, skip to step 2.

Step 1: Go to your application on the developer portal

Developer Portal

Step 2: Invite collaborators

  • Click on the newly created app to open the app settings page.
  • On the app settings page, click the "Invite" button.

Invite Button

  • Input the email of the user you want to invite and the role they will be assigned. Note that this needs to be the user's email that is associated with their Lucid account. Also, the "Tester" role can be assigned to users on your account or users on different accounts.

Invite Collaborator

Step 3: Remove collaborators

  • If necessary, click the trash can icon next to any collaborators you want to remove from your app.

What are the limitations of application collaborator roles?

There are some instances when a user who has been sent a collaborator invitation is unable to accept it. This happens when the following are true:

  1. The receiving user is from a different organization, and is trying to accept a role that can only be given to members of your company's Lucid account. See Who can I invite to collaborate? for more details.
  2. The admin control for self-selecting into the developer role is turned off on the account of the receiving user.
  3. The receiving user doesn't have the developer role assigned manually by the admin.

However, if the admin control for self-selecting into the developer role is turned on, accepting the collaborator invitation will automatically give the user access to developer tools, even if they didn't previously have access.

Admin controls

Lucid allows admins on Lucid Enterprise accounts to control which users can develop on Lucid’s APIs.

For Lucid’s REST and Extension APIs, admins can permit or restrict development by managing their users’ access to developer tools. With the instructions detailed below, admins can configure their account so:

  1. Any user on the account can develop on Lucid’s APIs.
  2. No users can develop.
  3. Only admin-designated users can develop.

Only enterprise accounts are able to restrict access to developer tools, meaning all users on Team accounts can develop on Lucid’s REST and Extension APIs

For Lucid’s SCIM API, admins permit or restrict use or development by managing access to their account’s SCIM bearer token. Please note that use of Lucid’s SCIM API is limited to Lucid Enterprise accounts.

Developer tools

Lucid provides two developer tools that allow Lucid users to create, test, and distribute apps built on Lucid’s REST and Extension APIs:

  1. The developer portal, which allows developers to create new apps and OAuth 2.0 credentials needed to hit Lucid’s REST APIs, and to distribute apps.
  2. The developer menu, which allows developers to test apps built on Lucid’s Extension API.

Users without these tools are unable to build or distribute code with these APIs.

Managing the SCIM API

A SCIM bearer token is needed for an app to use Lucid’s SCIM API. This token can only be obtained in the admin panel, it is not available via the developer portal or developer menu. Only account admins can access the bearer token for an account.

Managing the REST and Extension APIs

Lucid admins can control which users are able to build on Lucid’s REST and Extension APIs by managing which users have access to developer tools. Note that users do not need developer tools to connect to or use apps published in the Lucid marketplace.

Admins can configure their account in any one of the following ways:

  1. Any user on the account can develop on Lucid’s APIs.
  2. No user can develop on Lucid’s APIs.
  3. Only admin-designated users can develop on Lucid’s APIs.

Admins can configure this by enabling or disabling developer self-select in the “User feature controls” section of the admin panel. When self-select is disabled, admins can assign individual users developer permissions in the “Users” section of the admin panel.

The default configuration allows all users to develop.

For Lucid GovSuite, Lucidscale for Gov accounts, and Lucid for Education, the default configuration does not allow any users to develop. The ability to develop on Lucids APIs is currently unavailable on Lucid’s EU data center.

All users can develop

An admin can configure their account to allow any of their users to develop on Lucid’s REST and Extension APIs. Admins can do so by going to the “User feature controls” section of the admin panel, finding the “Developer controls” section, enabling the toggle, and pressing “Save changes.”

Note that an admin must be an Account Owner or Team Admin to enable this toggle (see admin roles for more info).

Developer controls

When the “Developer controls” toggle is enabled, users will be able to unlock developer features via their user settings. Please note that admins will be unable to see which users have unlocked developer features.

Developer settings

No users can develop

If an admin desires that no users are able to build apps on Lucid’s REST and Extension APIs, they need to 1) disable the “Developer controls” toggle in the admin panel, and 2) unassign the developer role from any user previously assigned it.

Note that the “Enable developer tools” setting will still be visible to users when the “Developer controls” toggle is disabled, but users will not be able to enable the setting.

Admin-designated users can develop

Admins are able to assign specific users the ability to build apps on Lucid’s REST and Extension APIs by assigning a user the developer role. Assigning a user the developer role unlocks developer tools for that user, even if they haven’t enabled developer features in their user settings. The developer role can be assigned to a user in the “Users" section of the admin panel.

Note that an admin must be an account owner or team admin to be capable of assigning the developer role (see admin roles for more info).
Also note that for security purposes, Lucid does not allow admins to assign themselves roles. This means admins are unable to assign themselves the developer role.

Edit roles

OAuth 2.0 Client Creation

In order to use any of the Lucid REST APIs, an app must have permission from the user to access their data. This permission can be granted with an OAuth 2.0 access token. Lucid allows developers to create an OAuth 2.0 client attached to their app for this purpose. This guide focuses on creating a new app in the developer portal along with its OAuth 2.0 client.

Goal

You’ll learn how to create an OAuth 2.0 client through the developer portal. This will include:

  • Creating a new application
  • Creating an OAuth 2.0 Client

Prerequisites

Glossary

Term
Definition
OAuth 2.0 OAuth 2.0 is an industry-standard authorization framework that enables third-party applications to access a user's protected resources on a server without exposing their credentials. It allows users to grant limited access to their data while maintaining control over their sensitive information.
Client ID A client ID is a unique identifier assigned to a client application by the authorization server. It is used to authenticate and authorize the client application when making requests to the server, helping establish the client's identity and permissions.
Client Secret A client secret is a confidential and securely stored string or passphrase that is assigned to a client application by the authorization server. It serves as a form of authentication for the client application when interacting with the server. The client secret is used to verify the identity of the client application and protect against unauthorized access.
Redirect URI The redirect URI in OAuth 2.0 is a specific endpoint or URL that the authorization server uses to redirect the user's browser back to the client application after authentication and authorization. It serves as a callback mechanism to deliver the authorization code or access token to the client application. The redirect URI helps complete the OAuth 2.0 flow and allows the client application to obtain the necessary authorization credentials.
Postman Postman is a popular API development and testing tool that allows developers to easily send HTTP requests, observe responses, and analyze API behavior.

Step by Step Walkthrough

Step 1: Create a new application on the developer portal

My Applications

  • Input a name for your application.
    • Note that this name can be changed in the app settings page later if necessary.

Create New Application

Step 2: Create an OAuth 2.0 client

  • Click on the newly created app to access its settings.
  • Navigate to the “OAuth 2.0” tab.
  • Input a name for your OAuth 2.0 client.
    • Note that this name will be publicly visible when users grant access to your application, and it cannot be changed.

Create Client

Step 3: Note the client ID and client secret

  • The client ID and client secret will be used to authenticate your application when users grant access to your application.
  • If the client secret ever becomes compromised and needs to be reset, click the “Reset Client Secret” button on this page.
    • Note that once the client secret is reset, it cannot be undone. Your app will immediately lose access to Lucid until the client secret is updated in your app as well.

Client Secret

Step 4: Register a Redirect URI

  • Click the “Add Redirect URI” button on the OAuth 2.0 settings page.
  • Input the URL that you want to redirect users back to once they have granted permissions to your app.
    • This should be a URL that the app controls. Lucid will append the authorization code to the URL in the code query parameter.
    • If using an API tool like Postman, you can use the Test Redirect URI to retrieve the code manually while developing your integration.
    • For more more information, reference the glossary.

Redirect URL

Limitations

When accessing Lucid APIs using OAuth 2.0, the following limitations apply:

  • If the app is not published, only specified app collaborators can use the app with your client ID and secret. If you would like to develop an app that all Lucid users can use, refer to this publishing documentation.
  • Authorization must happen in the browser, because a user must give consent for the client to access their information. For this reason, the authorization request cannot be made from most http clients.

Other Resources

Publishing

What is "Publishing"?

Publishing your app makes it easy for Lucid users to find and connect to your app. Lucid provides two publishing options:

  • You can request your application be published in Lucid's Marketplace. Doing so provides nearly every Lucid user the option to “connect” to and use your app. Please note Lucid will review your app before it's published to the Marketplace.
  • [Coming soon] A Lucid admin is able to publish an app internally. This means any user on your Lucid account can connect to the app.

For apps built on our Extension API, you will be able to publish specific versions of the extension package. Like a new app, each version of the extension package will be reviewed by Lucid before being released to the marketplace. When the published version changes, users connected to the app are automatically transitioned to the published version.

If you'd like to distribute your app to only select Lucid users, consider adding them to the app as a collaborator with the Tester role.

Please note that Lucid Account Admins can make any published app be connected by default for all users on their account.

Publish to Lucid Marketplace

This section describes how to publish your app to the Lucid marketplace. At a high-level, the process for publishing an app is:

  1. Ensure your app meets all publishing requirements.
  2. Submit the app for Lucid review.
  3. Lucid will review the app.
  4. If/when the app passes the review, Lucid will release it to the marketplace.

Requirements

Before requesting your app to be published on the Lucid marketplace:

  • Your app must have an extension package (i.e. be built on our Extension API), an OAuth 2.0 client (i.e. be built on our REST API), or both. Please see API-specific Requirements for additional requirements specific to which API your app uses.
  • Your app must have a completed marketplace listing page. This page allows you to explain to potential users the basics of your app (what it does, where to go for support, why your app is incredible, etc.).
  • You must create a ticket in Zendesk using this form (record the Zendesk ticket ID as you will need to provide it during submission). This ticket provides Lucid with information needed to review your app. Lucid will also use Zendesk to ask you any questions that arise during the app review.

Completing the marketplace listing

To complete the Marketplace listing, go to Marketplace listing in the application general section.

Create Listing

In the listing you will be able to provide general information about your application, images, support information, and more.

Applications with only an OAuth 2.0 client must provide a link (or multiple links) redirecting users to the webpage where they can connect to/authorize the app. You can either provide a single link, or one link per Lucid product supported by your application. If your OAuth 2.0-based app supports Lucid multiple products, it is important that the links are entered properly.

Once you have completed the listing, you will be able to see a preview of it and make sure everything is perfect.

View Listing

Submitting for Review

A user must have the App owner collaborator role to request an app be published for the first time (see Application Collaborator Roles). If the application has been previously published, users with the Code editor role can also request updates to the app (e.g. new package versions or adding an OAuth 2.0 client) be published. Other collaborators won't be able to see the UI for publishing.

You submit an app/update for approval in the Developer Portal, specifically using the Publish application UI in the Distribution section of the app's page. Here you can also publish a previously approved extension package version (this automatically reverts all users connected to the app to that version).

The steps to publish an app will be slightly different depending on the app's components.

  • If you have a package version, you will get to choose which version you want to publish.
  • If you have an OAuth 2.0 client, you will see the name of the client that will be published.
  • If you have both a package version and an OAuth 2.0 Client, both will be published at the same time.
  • Important: After you publish an application with only an OAuth 2.0 client, you will be unable to add extension packages to the app in the future.

To submit an app with an extension package for review, you must select the package version (if there is an eligible one) that will be published. You will be able to select any package version that has not been revoked, currently published, or under review.

You can publish a previously approved package version at any time, which will trigger an automatic transition to that version for all connected users. Lucid provides this option in case there are unexpected issues with a new version and you need to quickly revert to a stable version.

If your application has an unpublished OAuth 2.0 client, it will be automatically included in the review process.

The action button will be disabled if certain publish requirements have not yet been met. You can hover your mouse over the button to see which requirements have not been met.

Clicking the action button will trigger either the app review process or publishing if no approval is needed. If the Lucid review process is required, you will be prompted to enter the Zendesk ticket ID discussed here. The link to create the Zendesk form will also be present on the modal.

Submission

Refer to the Application publication status to see the updated publication information of your app.

Checking your application status

The Publication status section will display information on which users can access your application, if a package version or OAuth 2.0 client is publicly available, and any pending review process.

Every collaborator on the application will be able to see the publication status.

Publication Status

Once the application has been reviewed and approved by our team, it will be automatically published on Lucid's marketplace. If you are publishing a new extension package version, every user with the current previous published version will automatically update to the new version.

Publish Internally

Coming soon!

API-specific Requirements

To publish an app (either internally or to the Lucid Marketplace), the app must meet requirements specific to the API it uses. Apps using both APIs must meet both sets of requirements.

REST API

These requirements apply if your application uses our REST API (e.g. has an OAuth 2.0 Client). Refer to the OAuth 2.0 Client Creation Walkthrough for steps on how to create an OAuth 2.0 Client. Please note that applications built on our legacy REST APIs can not be published.

Requirements:

  • The name of your OAuth 2.0 client must accurately represent your application. This name will be shown to users when Lucid asks them whether they authorize your app to interact with their data.
  • Verify you have registered all redirect URIs your application will use.

Extension API

These requirements apply if your application uses our Extension API (e.g. has an extension package). Go to your application in the Lucid Developer Portal and click on the Packages tab. In this page, you will be able to see every package version for your application along with the publication status and installation status.

Package List

You can click on a package version to see more information about the version, as well as the “Install for me”, “Install for my account”, and “Revoke” options.

Package Details

You cannot revoke a published package version and revoked package versions cannot be published.

In order to publish your application with a package extension, you must have previously created a package version. If you haven't created one, follow the steps in Upload your extension package to do it. Once you are done, you will see a new entry on the table.

Uploading an Extension Package

Clicking on your application should take you to a page with a URL in the form https://lucid.app/developer#/packages/<UUID>. Copy this UUID and paste it into the id field of your manifest.json.

Then, once your editor extension (and other extension package content) works the way you want, you can bundle it for upload to the Lucid Developer Portal with the following command:

npx lucid-package bundle

This will create the file package.zip file which is ready for upload to the Lucid Developer Portal. Go to your application, and click on the “Packages” tab. For more information look at Bundle your package for upload.

No Package

Then, click on the “+ New Version” button, and finally upload and submit the “package.zip” file generated in the previous step.

Upload New Version

A new package version will be created, and will be ready to be published. You can follow this process anytime you need to upload new code for your extension package. Remember to update the version field in the manifest.json file as there cannot be repeated versions.

Custom shape libraries

What is a custom shape library?

Lucidchart and Lucidspark both support custom, user-created shape libraries. Custom shape libraries appear in the left panel of Lucid documents alongside Lucid's built-in shape libraries. Users can drag shapes from any custom shape libraries they have installed onto the canvas as they’re creating their documents.

Custom Shape Example

To create a shape library, you can use the Extension API to create a shape library extension that can be published and shared through Lucid’s extension marketplace.

What can a custom shape library do?

Lucid's custom shape library framework allows you to create all sorts of powerful shapes. The following are features of custom shapes you can leverage.

Conditional formatting

Conditional Formatting

Grouping and nesting

Grouping and Nesting

Images

Images

Data linking

Data linking

Anchor points

Anchor points

Repeat geometries

Repeat geometries

By combining these features, you can create intelligent shapes that bring clarity to your visualizations.

What are the limitations of custom shape libraries?

  • User-defined international/i18n translations are parsed and verified but aren't currently used.
  • Formatting inside of text areas is not implemented (text areas as a whole can be formatted, but the format can't change within the text area).

Examples of custom shape libraries:

Get started building a custom shape library:

If you're interested in building your own custom shape library, you can follow our comprehensive walkthrough.

Link Unfurling

Link unfurling enables you to enrich the Lucid canvas by converting static text links into dynamic shapes. These shapes can include image thumbnails and titles, providing a more visually appealing and informative display for external users. By utilizing link unfurling, you can easily make content more captivating and interactive.

Unfurling Introduction

A link unfurling integration has two primary elements: its block and its control options.

Link unfurl block: Generated from an external URL, this block shows a thumbnail image and a page title, both sourced from the URL, alongside the data source's logo and name. Additionally, a text area at the bottom of the block allows users to enter detailed descriptions.

Link unfurl control options: Control options for the internal embed extension vary by Lucid product. In Lucidchart, the options are located in the context menu on the right-hand side of the canvas. In Lucidspark, they can be found at the top of the shape.

Embedded Link

Pasted Link

Control options vary depending on the type of content being embedded.

These options are available for all internal embeds:

The ability to copy the source link in the embedded thumbnail

The ability to open the embedded link in a new tab

The ability to refresh and resync with the data source

These options are available if there is at least one thumbnail image available:

The ability to toggle and choose an image to display on the thumbnail (when multiple thumbnail images are available)

The ability to extract one or more thumbnail images to use as shapes on the canvas

This option is available if the iframe URL is provided by your extension:

The ability to expand the embedded link within the document or board for viewing and editing, using an iframe URL provided by your extension

Pasted Document

  • Once thumbnail images have been extracted, it is not possible to resync them with the data source for the image shapes. However, a potential workaround is to re-extract the image after resyncing with the data source in the internal embed shape.
  • Bidirectional data sync is not supported, meaning that while data can be imported into Lucid, updates to titles or images cannot be pushed back to the data source. A possible workaround for this issue is to use an iframe editor.
  • Doesn't support in-editor video players. Video links unfurled need to be opened in a new window/tab.

See more:

How to build internal embed integration

Data import and sync

What is data import and sync?

This function enables developers to take data from an external data source and pull it into Lucid. This can be done by:

  1. Hitting a third-party API and manually retrieving data.
  2. Setting up data connectors, which automatically pull data from third-party APIs.

Data import

What can data import and sync do?

  1. It can automatically pull data from external data sources to update properties of any Lucid element that can be data linked (shapes, lines, groups, or pages). Such properties include text, color, and more.

Data Sync

  1. Any changes made to the data in Lucid will reflect back in the external data source. Pushing updates back to the external source can be made automatic or designed to appear to the user in stages or batches.

Push Back

How does data import and sync work?

Data import and sync relies on Lucid's Extension API, and uses:

  1. Editor Extensions to manage how data appears and acts on Lucid documents.
  2. (Optionally) Data Connectors to automatically fetch changes made in third party systems, and to automatically push changes made in Lucid back to third party systems.
  3. (Optionally) Shape Libraries to create custom shapes for displaying data.

You can step through the entire process of building a data import and sync cards integration here.

What are the limitations of data import and sync?

  1. Data imported via extensions does not currently work with Lucid's out-of-the-box solution for org charts or timelines. Nevertheless, it's possible for the extension to manually construct such objects.
  2. It's easy to work with data that can be represented tabularly. However, data formats that are not efficiently represented tabularly will be more difficult to work with due the the way Lucid stores data on the document.

Examples of data import and sync implementations

Using web frameworks

When developing with the Extension API, you can utilize web frameworks to display content onto a panel or modal. The following sections will go over using Angular and React, but other frameworks can be used as well.

Angular

Complex custom UI built directly with HTML and Javascript can be difficult to write and maintain. There are a number of popular frameworks you can use to make UI development more scalable. Angular is one popular option. This section will walk you through building a simple Angular application that is displayed in a custom panel.

Step 1: Create the editor extension

Start by creating a new empty extension package, and adding an editor extension to that package:

npx lucid-package create ngtest cd ngtest npx lucid-package create-editor-extension with-cool-ui

Step 2: Create the Angular app and start the dev server on it

Now you will use the Angular CLI to create a new Angular application called rightpanel inside the with-cool-ui editor extension. Do these steps in a separate console, as you'll want to leave the ng serve process running separately from the lucid-package dev server:

npm install @angular/cli webpack-shell-plugin-next mkdir -p public/rightpanel cd editorextensions/with-cool-ui npx ng new rightpanel cd rightpanel npx ng serve # Leave this running while developing

Step 3: Replace webpack.config.js for the editor extension

Here you will use webpack-shell-plugin-next (which was installed in Step 2) to prepare the Angular app for use in both development and release modes.

onWatchRun will run whenever you start up npx lucid-package test-editor-extension. Here, the script is reading the main HTML file from the Angular dev server (ng serve) that you started running in Step 2. It makes sure all of the URLs in that file are absolute (by prepending http://localhost:4200 to them) so that they will resolve correctly in your new panel's iframe. It then writes that resulting HTML out to a file in the public directory so you can use it in your extension code.

onBeforeNormalRun will run whenever you build your package for deployment with npx lucid-package bundle. Here, the script runs a full ng build inside the rightpanel directory, then copies all the assets to the root level public folder you created in step 2. While you could do this same operation for onWatchRun, it is much slower than allowing ng serve to directly provide the code during development:

const path = require('path'); const WebpackShellPluginNext = require('webpack-shell-plugin-next'); const angularTargets = [{name: 'rightpanel', port: 4200}]; module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, plugins: [ new WebpackShellPluginNext({ //When doing a watch build, run "ng serve" and update the html file to prefix http://localhost:4200/ to all the resource URLs onWatchRun: { scripts: angularTargets.map( (target) => `mkdir -p ../../public/${target.name} &&` + `curl http://localhost:${target.port} | ` + `sed -E "s/(src|href)=\\"/\\\\1=\\"http:\\/\\/localhost:${target.port}\\//gi" > ` + `../../public/${target.name}/index.html`, ), blocking: true, }, //When doing a full build, run "ng build" and then copy all the assets to the root level public folder onBeforeNormalRun: { scripts: angularTargets.map( (target) => `mkdir -p ../../public/${target.name} &&` + `cd ${target.name} && ` + // `npx ng build` usually works, but this is more reliable when used with build tools such as bazel `./node_modules/.bin/ng build && ` + `cp -r dist/${target.name}/* ../../../public/${target.name}` ), blocking: true, swallowError: false, safe: true, }, }), ], mode: 'development', };

Step 4: Use the Angular app in a panel

Update src/extension.ts:

import {EditorClient, Panel, PanelLocation, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); export class RightPanel extends Panel { private static icon = 'https://lucid.app/favicon.ico'; constructor(client: EditorClient) { super(client, { title: 'From Angular', url: 'rightpanel/index.html', location: PanelLocation.RightDock, iconUrl: RightPanel.icon, }); } } const rightPanel = new RightPanel(client);

Step 5: Run the Lucid dev server

Make sure your ng serve process is running (see Step 2) before doing this step, or your panel may not work. Remember that running test-editor-extension will trigger the onWatchRun script that generates the correct HTML for the panel to work:

npx lucid-package test-editor-extension with-cool-ui

Step 6: Write your Angular app

With both the ng serve and lucid-package test-editor-extension dev servers running, the dev cycle for updating the Angular app is just editing its code and then reloading that iframe. For modals, that means closing and reopening the modal; for panels it means switching to the normal context panel and back. No need to reload the whole editor.

To use static assets in your Angular app, you will need to place your static assets in package root level's public folder under public/rightpanel. You can then reference those assets in your Angular app using <img src="img/example.png">.

If you are still unable to load static assets, make sure to remove the line <base href="/"> in your Angular App's index.html.

If you want to share classes or other code from your extension to your UI, then just add the following in rightpanel/tsconfig.json:

{ // ... "compilerOptions": { // ... "paths": { "@extension/*": ["../src/*"] }, } }

Then you will be able to import, for example, from with-cool-ui/src/sharedthing.ts like this:

import {SharedThing, SharedClass} from '@extension/sharedthing';

Remember, of course, that just because you're sharing code doesn't mean you're in a shared runtime. You still have to send serializable messages back and from from your UI project like this. You could easily make a simple Angular injectable that receives messages that you can use in your UI components:

import {Injectable} from '@angular/core'; @Injectable() export class DataFromExtension { public ids: string[] = []; constructor() { //Listen for lists of selected item IDs window.addEventListener('message', (event) => { if (event.data['ids']) { this.ids = event.data['ids']; } }); //Once ready to receive those messages, ask the extension to refresh data parent.postMessage('refresh', '*'); } }

You can add something like this to your Panel class to keep your UI updated any time the current selection changes:

export class RightPanel extends Panel { private readonly viewport = new Viewport(this.client); constructor(client: EditorClient) { //... this.viewport.hookSelection(() => this.sendStateToFrame()); } private sendStateToFrame() { this.sendMessage({ ids: this.viewport.getSelectedItems().map((i) => i.id), }); } //When the app is loaded, it will send a message asking for an update. protected messageFromFrame(message: any) { if (message === 'refresh') { this.sendStateToFrame(); } } }

Step 7 (bonus): Add drag and drop functionality

You can add controls to your custom panels that allow users to drag and drop a new block from your panel onto the canvas.

Custom drag and drop

Use Viewport.startDraggingNewBlock from the panel

In the RightPanel class you created above, you can start listening for messages from your Angular app.

You will be sending a few possible messages:

  • drag to indicate the user has started dragging out of the panel.
  • pointermove indicating the user is dragging the content over the canvas at a particular location.
  • pointerup indicating the user has dropped the content on the canvas at a particular location.
  • cancelDrag indicating the user is no longer dragging content from the panel.

You will also send a message from the extension to the panel's Angular app: dragDone indicating the user has successfully dropped the shape onto the canvas, or has otherwise cancelled the operation (e.g. by pressing Escape).

The code of the RightPanel looks like this:

export class RightPanel extends Panel { // ... protected async messageFromFrame(message: any) { if (message.message == 'drag') { const maybeBlock = await this.viewport.startDraggingNewBlock({ className: 'ProcessBlock', boundingBox: {x: 0, y: 0, w: 200, h: 200}, properties: {'Text': 'Custom Text'}, }); if (maybeBlock) { maybeBlock.properties.set('FillColor', '#ff00ffff'); } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } }

You can see that startDraggingNewBlock returns a Promise that resolves to either the newly created block itself, or undefined if the operation was cancelled. You can use this to make changes to the new block (or carry out any other operation you need to perform) as soon as the block is dropped on the canvas.

Here, you are just creating a new standard block type, but this operation works just as well with custom shapes from your shape libraries, like this:

export class RightPanel extends Panel { // ... private scoreBarDefinition:BlockDefinition|undefined; constructor(client: EditorClient) { // ... this.client.getCustomShapeDefinition('shapes', 'score-bar').then(def => { this.scoreBarDefinition = def; }); } //When the app is loaded, it will send a message asking for an update. protected async messageFromFrame(message: any) { if (message.message == 'drag') { if (this.scoreBarDefinition) { const maybeBlock = await this.viewport.startDraggingNewBlock(this.scoreBarDefinition); if (maybeBlock) { maybeBlock.properties.set('FillColor', '#ff00ffff'); } } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } }

Write the Angular component

Writing a well-behaved drag and drop source requires some care. This example has all of the following behaviors:

  • The element they drag should move with the mouse cursor when they start dragging.
  • The element they drag should disappear from the panel when they move onto the canvas.
  • The element they drag should move back to its original location if the user completes or cancels the drag in any way.

Here is some sample code performing all of these operations with a simple div as the dragged element:

app.component.html
<div class="drag" (pointerdown)="pointerDown($event)" > Drag me </div>
app.component.less
div.drag { width: 100px; height: 100px; border: 4px solid red; }
app.component.ts
import {Component} from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.less'], }) export class AppComponent { private pointerDownEvent: PointerEvent | undefined; //As of the last pointer event, is the (captured) pointer outside the iframe's bounds? private pointerIsOut = false; private documentPointerUp = (e: PointerEvent) => { if(this.pointerIsOut) { //Notify the editor that it needs to simulate canvas pointer events parent.postMessage({message:'pointerup', x:e.pageX - window.scrollX, y:e.pageY - window.scrollY}, '*'); } stopDrag(); }; private isInsideFrame(e: PointerEvent) { const x = e.pageX - window.scrollX; const y = e.pageY - window.scrollY; return x >= 0 && x <= window.innerWidth && y >= 0 && y <= window.innerHeight; } private documentPointerMove = (e: PointerEvent) => { const isInside = this.isInsideFrame(e); if(!this.pointerIsOut && !isInside) { this.startCanvasDrag(); } else if(this.pointerIsOut && isInside) { //If the pointer has re-entered the iframe while dragging, tell the extension to //cancel the ongoing interaction for dragging the new block out. this.stopCanvasDrag(); } this.pointerIsOut = !isInside; //While dragging the HTML element around, move it around //with relative positioning to keep it attached to the pointer cursor. const target = this.pointerDownEvent?.target; if (this.pointerDownEvent && target instanceof HTMLElement) { target.style.position = 'relative'; target.style.top = e.pageY - this.pointerDownEvent.pageY + 'px'; target.style.left = e.pageX - this.pointerDownEvent.pageX + 'px'; } if(isInside) { //Defense in depth: If somehow the pointer buttons have been released and the user //is moving the pointer over this iframe again, cancel any ongoing drag operation. if (e.buttons == 0) { this.stopDrag(); } } else { //Notify the editor that it needs to simulate canvas pointer events parent.postMessage({message:'pointermove', x:e.pageX - window.scrollX, y:e.pageY - window.scrollY}, '*'); } }; //If you have asked the extension to start a drag-new-block interaction, you need //to listen for a message indicating that interaction has completed (either //successfully or not) in order to reset the drag/drop state entirely. private windowMessage = (e) => { if (e.data === 'dragDone') { stopDrag(); } }; private startCanvasDrag() { parent.postMessage({message: 'drag'}, '*'); window.addEventListener('message', this.windowMessage); } private stopCanvasDrag() { window.removeEventListener('message', this.windowMessage); parent.postMessage({message:'cancelDrag'}, '*'); } //Start listening for pointer events on this iframe to implement drag & drop. private startDrag() { window.document.addEventListener('pointerup', this.documentPointerUp); window.document.addEventListener('pointermove', this.documentPointerMove); } //Cancel drag & drop, and reset the DOM back to how it began. private stopDrag() { const target = this.pointerDownEvent?.target; if (this.pointerDownEvent && target instanceof HTMLElement) { target.style.position = 'static'; target.style.top = ''; target.style.left = ''; this.pointerDownEvent = undefined; } window.document.removeEventListener('pointerup', this.documentPointerUp); window.document.removeEventListener('pointermove', this.documentPointerMove); this.stopCanvasDrag(); } public pointerDown(e: PointerEvent) { //Store the event that started the drag, as a coordinate anchor. this.pointerDownEvent = e; this.pointerIsOut = false; this.startDrag(); } }

Using React

Similarly, you can use React to make custom UIs. This section will walk you through building a simple React application that is displayed in a custom panel.

Step 1: Create the editor extension

Start by creating a new empty extension package, and adding an editor extension to that package:

npx lucid-package create reacttest cd reacttest npx lucid-package create-editor-extension with-cool-ui

Step 2: Create the React app and start the dev server on it

Now you will use Create React App to bootstrap a new React application called rightpanel inside the with-cool-ui editor extension. Do these steps in a separate console, as you'll want to leave the npm start process running separately from the lucid-package dev server:

npm install webpack-shell-plugin-next mkdir -p public/rightpanel cd editorextensions/with-cool-ui/ npx create-react-app rightpanel --template typescript cd rightpanel npm start # Leave this running while developing

Step 3: Replace webpack.config.js for the editor extension

Here you will use webpack-shell-plugin-next (which was installed in Step 2) to prepare the React app for use in both development and release modes.

onWatchRun will run whenever you start up npx lucid-package test-editor-extension. Here, the script is reading the main HTML file from the React dev server (npm start) that you started running in Step 2. It makes sure all of the URLs in that file are absolute (by prepending http://localhost:3000 to them) so that they will resolve correctly in your new panel's iframe. It then writes that resulting HTML out to a file in the public directory so you can use it in your extension code.

onBeforeNormalRun will run whenever you build your package for deployment with npx lucid-package bundle. Here, the script runs a full npm run build inside the rightpanel directory, then copies all the assets to the root level public folder you created in step 2. While you could do this same operation for onWatchRun, it is much slower than allowing npm start to directly provide the code during development:

const path = require('path'); const WebpackShellPluginNext = require('webpack-shell-plugin-next'); const reactTargets = [{name: 'rightpanel', port: 3000}]; module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, plugins: [ new WebpackShellPluginNext({ //When doing a watch build, run "npm start" and update the html file to prefix http://localhost:3000/ to all the resource URLs onWatchRun: { scripts: reactTargets.map( (target) => `mkdir -p ../../public/${target.name} &&` + `curl http://localhost:${target.port} | ` + `sed -E "s/(src|href)=\\"/\\\\1=\\"http:\\/\\/localhost:${target.port}\/gi" > ` + `../../public/${target.name}/index.html`, ), blocking: true, }, // When doing a full build, run "npm run build" and then copy all the assets to the root level public folder onBeforeNormalRun: { scripts: reactTargets.map( (target) => `mkdir -p ../../public/${target.name} &&` + `cd ${target.name} && ` + `npm run build && ` + `sed -i -E "s/(src|href)=\\"\\//\\1=\\"\/gi" build/index.html &&` + `cp -r build/* ../../../public/${target.name}` ), blocking: true, }, }), ], mode: 'development', };

Step 4: Use the React app in a panel

Update src/extension.ts:

import {EditorClient, Panel, PanelLocation, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); export class RightPanel extends Panel { private static icon = 'https://lucid.app/favicon.ico'; constructor(client: EditorClient) { super(client, { title: 'From React', url: 'rightpanel/index.html', location: PanelLocation.RightDock, iconUrl: RightPanel.icon, }); } } const rightPanel = new RightPanel(client);

Step 5: Run the Lucid dev server

Make sure your npm start process is running (see Step 2) before doing this step, or your panel may not work. Remember that running test-editor-extension will trigger the onWatchRun script that generates the correct HTML for the panel to work:

npx lucid-package test-editor-extension with-cool-ui

Step 6: Write your React app

With both the npm start and lucid-package test-editor-extension dev servers running, the dev cycle for updating the React app is just editing its code and then reloading that iframe. For modals, that means closing and reopening the modal; for panels it means switching to the normal context panel and back. No need to reload the whole editor.

You might observe that the static assets generated by Create React App are not being loaded properly. To fix this issue, all you need to do is to place your static assets in package root level's public folder under public/rightpanel. You can then reference those assets in your react app using <img src="img/example.png">.

Notice this is not the public folder in your react app.

In order to share classes or other code between your extension and UI, you will need to install either craco or react-app-rewired. These tools allow you to override the default webpack settings used by Create React App:

cd rightpanel npm install @craco/craco

Then create a file called craco.config.js in the rightpanel directory:

const path = require('path'); module.exports = { webpack: { alias: { '@extension': path.resolve(__dirname, '../src'), }, configure: webpackConfig => { const scopePluginIndex = webpackConfig.resolve.plugins.findIndex( ({ constructor }) => constructor && constructor.name === 'ModuleScopePlugin' ); webpackConfig.resolve.plugins.splice(scopePluginIndex, 1); return webpackConfig; } }, };

Then create a file called tsconfig.paths.json in the same directory:

{ "compilerOptions": { "paths": { "@extension/*": ["../src/*"] } } }

In tsconfig.json:

{ // ... "extends": "./tsconfig.paths.json", }

Then you should change all react-scripts in package.json into craco:

{ // ... "scripts": { "start": "craco start", "build": "craco build", "test": "craco test", "eject": "craco eject" } }

Then you will be able to import, for example, from with-cool-ui/src/sharedthing.ts like this:

import {SharedThing, SharedClass} from '@extension/sharedthing';

Remember, of course, that just because you're sharing code doesn't mean you're in a shared runtime. You still have to send serializable messages back and from from your UI project like this. You could easily add a eventListener in the useEffect hook in your UI components:

import React, { useEffect, useState } from 'react'; import './App.css'; function App() { const [ids, setIds] = useState([]); const handleMessage = (event: MessageEvent<any>) => { if (event.data['ids']) { setIds(event.data['ids']); } } useEffect(() => { window.addEventListener('message', handleMessage); //Once ready to receive those messages, ask the extension to refresh data window.parent.postMessage('refresh', '*'); return () => { window.removeEventListener('message', handleMessage); }; }, []); return ( <div className="App"> <div>selected Ids: {ids}</div> </div> ); } export default App;

You can add something like this to your Panel class to keep your UI updated any time the current selection changes:

export class RightPanel extends Panel { private readonly viewport = new Viewport(this.client); constructor(client: EditorClient) { //... this.viewport.hookSelection(() => this.sendStateToFrame()); } private sendStateToFrame() { this.sendMessage({ ids: this.viewport.getSelectedItems().map((i) => i.id), }); } //When the app is loaded, it will send a message asking for an update. protected messageFromFrame(message: any) { if (message === 'refresh') { this.sendStateToFrame(); } } }

External embeds

What is an external embed?

An external embed enables you to add a Lucid document directly in your webpage. This view-only version can be configured to automatically and near-instantly update whenever changes are made to the original Lucid document. Users can also interact with the Lucid document by zooming in and out or panning through the document.

Lucidspark document embedded in Confluence

Embed types

Lucid's API offers two solutions for external embeds: token-based and cookie-based. With our token-based embed solution, anyone with access to your webpage can view the document, even if they don't have a Lucid account. With our cookie-based embed solution, a user is required to be a collaborator on the document in order to view the embed in your webpage.

Token-based

  • Choose this solution when you want users on your webpage to see Lucid documents, even if they don't have access to the document in Lucid or even a Lucid account.
  • The embedded document picker available for the token-based embed enables your users to determine which document to embed.
  • This solution relies on the OAuth 2.0 authentication standard: Any user adding a Lucid document to your webpage will first be asked whether they give permission to your application to do so.
  • As part of this solution, Lucid provides a document picker component. Your app can incorporate this component to enable users to select which Lucid document to embed.
  • Developers can configure token-based embeds to either stay up-to-date with any changes made to the Lucid document or to always be a static snapshot reflecting what the document looked like at the time it was inserted.

API Picker

  • Choose this solution if you're prioritizing a fast development cycle and if you're comfortable with the embed only being viewable by users who have permission to view the document in Lucid.
  • This solution is designed for a user to copy the Lucid URL of a document and paste it into your web application. Your application would then call Lucid's API and display the embedded viewer. Please note that no document picker is available with our cookie-based embed.
  • The user must be logged in to Lucid to view the embed in your webpage. This is because the embed utilizes browser cookies to authorize displaying the embedded document in your webpage.
  • The user must have permission to view the Lucid document to view the document in your webpage. If a user's view permissions to the document is removed, the embed will no longer be visible on your webpage.
  • This is the simplest type of embed to build, as it only requires you to hit one endpoint in an iframe. It's also the simplest to use as the user only needs to copy and paste the Lucid document's URL.

What are the limitations of external embeds?

  • Lucid external embeds are view-only. Users are unable to edit the Lucid document directly in your webpage. Instead, Lucid embeds provide a button that users click to open the document in Lucid in a new tab and edit there, as long as they have edit permissions to the document.
  • The document picker (part of the token-based solution) and cookie-based solution both require third-party cookies to be enabled in the browser. Some browsers disable these cookies by default. Information on enabling third-party cookies can be found here: Enabling third party cookies.
  • Embedding a Team Space is not currently supported. Additionally, although Lucidspark breakout boards can be embedded, they are not selectable via the document picker.
  • While the token-based embedded experience provides a document picker, there is no document picker available for cookie-based embeds.
  • The token-based embedded experience requires your integration to manage the granting and storage of OAuth 2.0 tokens securely. Cookie-based embeds do not require any token management. When viewing a cookie-based embed, users who do not have access will see a login or request-access screen instead.

Examples of external embed implementations:

Technical documentation

Step 1: Create an application

  1. Go to the developer portal
  2. Create a new application
  3. Create an Oauth2 client
  4. Register the authorized embedding domain. To try it out in Codepen, put https://cdpn.io in the list.
  5. Grab your client ID. You’ll need it in the next step!

Add Authorized Domain

If you have a Lucid document URL (such as from a customer pasting it) and you want to embed it in your app, you can use an iframe with the URL https://lucid.app/embeds?clientId=<client ID>&document=<url>. For example, toss the following code in a CodePen to try it out.

html
<p>Paste a Lucid link to see the document below!</p> <iframe referrerpolicy="strict-origin" style="width: 700px; height: 500px;"> </iframe>
javascript
const lucidIframe = document.querySelector('iframe'); // make sure you register cdpn.io in your dev portal for this demo to work const clientId = 'YOUR_CLIENT_ID'; document.addEventListener('paste', (event) => { const maybeUrl = event.clipboardData.getData('text/plain'); if (maybeUrl.startsWith('https://lucid.app/')) { const iframeUrl = new URL('https://lucid.app/embeds/link'); iframeUrl.searchParams.set('document', maybeUrl); iframeUrl.searchParams.set('clientId', clientId); lucidIframe.src = iframeUrl.toString(); } })

The embed will use the share settings on the link that was pasted.

Token-based

A step-by-step guide to use the token-based OAuth 2.0 external embed is coming soon! If you're looking to create an external embed that's available to everyone, sign up here to be a technology partner! We'd love to learn more about how you use Lucid and take our partnership to the next level.

For additional access or technical questions, please contact us in our Lucid developer forum.

Lucid Cards Overview

What are Lucid Cards?

Lucid Cards provide a way to visually create and track tasks across the entire project workflow. You can assign stakeholders to work, set deadlines, and visualize how these tasks interact with each other.

Lucid Cards support rich, data-driven integrations for third-party task management systems. For example, you can import tasks from task management systems such as Airtable, Azure, Google Sheets, Monday.com, Jira, and more into Lucidspark as Lucid Cards to organize and plan tasks visually. Ideate and brainstorm with your team, then put those ideas into action by pushing tasks directly into your task management system of choice. Having this two-way sync feature ensures that changes are always reflected and both platforms are always up to date.

Cards Introduction

There are two ways to view the data in Lucid Cards. First, you can view them as shapes for a quick summary. Or, you can view more detailed versions for full context.

Lucid Cards shape: The shape of Lucid Cards provide a brief summary of task-related information. In your application, you have the flexibility to customize their content and arrangement.

Lucid Cards detail view: By clicking on the pencil icon, you can access the detailed view. In the displayed menu, you'll find all the relevant fields specific to the task. The available fields can be customized within your application.

Cards Details

Within the detail view for Lucid Cards, you can click the gear icon to bring up the Lucid Cards settings panel. This panel allows you to configure fields visible in the Lucid Cards detail view and to link or unlink the integration from the third party.

Configure fields tab

You can also unlink active accounts you've already connected.

Manage connection tab

How to create a data-synced card

There are four ways to create data-linked Lucid Cards.

Import from the left toolbar

In your application, you have the option to set up an import modal, which enables users to directly import data from third-party task management systems.

  1. Click the card integration icon in the left toolbar.
  2. Select the Import option in the callout.
  3. Authorize with the third-party task management system.
  4. In the import modal, search for the task to import.
  5. Click Import.

Import Cards

Create from the left toolbar

If you successfully configured two-way sync, you'll be able to create a new task from the left toolbar. This action will result in the creation of the task within the third-party task management system.

  1. Click the card integration icon in the left toolbar.
  2. Select the New task option in the callout.
  3. Authorize with the third-party task management system if you haven't already.
  4. Provide the necessary details to create a new task.
  5. Click Create.

Create Card

Convert from regular Lucid Cards

If you successfully configured two-way sync, the following action will result in the creation of the task within the third-party task management system.

  1. Select the Lucid Cards icon on the left toolbar.
  2. Click anywhere on the board or drag the Lucid Cards shape out.
  3. Edit the card's content.
  4. Click on the card and click the wizard hat icon in the option bar.
  5. Choose the card integration to convert to.
  6. Follow the prompts.
  7. Click Create.

Convert Card

Convert an existing shape

If you successfully configured two-way sync, the following action will result in the creation of the task within the third-party task management system.

  1. Right click a shape.
  2. Select Convert to Card.
  3. Choose the card integration to convert to.
  4. Follow the prompts.
  5. Click Create.

Convert Shape

What are the limitations of Lucid Cards?

  • Currently, cards created as extensions don't work in Timelines.
  • The custom fields that appear in the card details panel will be the same across the document.
  • Card visualization is defined at build time, so users are unable to customize visualization.

Examples of Lucid Cards implementations

Lucid Card Integrations

Introduction

Lucidspark allows rich data-driven integrations for task management systems. A Lucid card integration can:

  • Import tasks from a task management system and add them as cards on a Lucidspark board.
  • Create new cards on a Lucidspark board that sync back to the task management system as new tasks.
  • Convert existing shapes, like sticky notes, to cards that sync back to the task management system as new tasks.
  • Almost instantaneously sync changes made to data on Lucidspark cards back to the task management system.
  • Almost instantaneously sync changes made in the task management system to any connected Lucidspark board.

Building a Lucid card integration requires the following steps:

  • In an editor extension, extend LucidCardIntegration and implement the functionality you want to support.
  • Define an OAuth provider in your package manifest to allow API calls to the data source.
  • Build a publicly-routable data connector that can read and write from the data source, and include configuration for it in your package manifest.

This section of the developer guide will walk you through building a Lucid card integration to visualize Todoist tasks.

Goal

By the end of this guide you will have created a Lucid cards Todoist integration that allows you to import your Todoist tasks into Lucidspark.

Prerequisites

Make sure that you have the latest versions of lucid-package and lucid-extension-sdk npm modules installed, and you have access to the developer portal. Check out the Getting started section for details.

Step by Step Walkthrough

Create the application

Run the following commands to create a new application and set up an editor extension within that application:

npx lucid-package create todoist cd todoist npx lucid-package create-editor-extension todoist

Extend LucidCardIntegration

In the folder /editorextensions/todoist/src create a new file todoistcardintegration.ts. In this file, create a new class that extends LucidCardIntegration:

/editorextensions/todoist/src/todoistcardintegration.ts
import {LucidCardIntegration, EditorClient} from 'lucid-extension-sdk'; export class TodoistCardIntegration extends LucidCardIntegration { constructor(private readonly editorClient: EditorClient) { super(editorClient); } // You will fill this in soon }

Now that you have created a new Lucid card integration, you need to construct and register it. Replace the code in extension.ts with the following:

/editorextensions/todoist/src/extension.ts
import {EditorClient, LucidCardIntegrationRegistry} from 'lucid-extension-sdk'; import {TodoistCardIntegration} from './todoistcardintegration'; const client = new EditorClient(); LucidCardIntegrationRegistry.addCardIntegration(client, new TodoistCardIntegration(client));

Initialize constants

A functional Lucid card integration depends on two individual components, the editor extension and the data connector, and it is essential to ensure proper communication between these two components. In particular, you will need to make sure that the schema used to represent the data in the extension and the data connector remain in sync.

Create a new folder called common in the root of your application, then create a new file called names.ts which will store keys which will be used by both the data connector, and the editor extension:

/common/names.ts
export const DataConnectorName = 'todoist'; export const CollectionName = 'Tasks'; // You will use this later to store the data you get from Todoist // You will add more constants here in the future

Basic configuration

Now you can begin configuring your Lucid card integration. These fields are required for every Lucid card integration:

  • label: the name of the integration, e.g. "Todoist".
  • itemLabel: what to call one data item, e.g. "Task" or "Todoist task".
  • itemsLabel: what to call multiple data items, e.g. "Tasks" or "Todoist tasks".
  • iconUrl: a URL (a data URI is fine) pointing to an icon representing the integration. This will be displayed at up to 32x32 CSS pixels in size.
  • dataConnectorName: the name of the data connector for this card integration. This will be specified in the package manifest.

We also allow you to optionally configure some more fields that let you customize your card integration:

  • textStyle: styles to be applied to all text on any cards created by this integration.
/editorextensions/todoist/src/todoistcardintegration.ts
export class TodoistCardIntegration extends LucidCardIntegration { // ... public label = 'Todoist'; public itemLabel = 'Todoist task'; public itemsLabel = 'Todoist tasks'; public iconUrl = 'https://cdn-cashy-static-assets.lucidchart.com/extensibility/packages/todoist/Todoist-logo-for-card.svg'; public dataConnectorName: string = DataConnectorName; // Imported from names.ts that was created above }

Field configuration

Many task management systems can have hundreds of fields, and it may be impractical or not useful to import all of them. To that end, card integrations allow the developer to configure which fields should be imported.

Define all the fields you want to import from Todoist in names.ts:

//common/names.ts
// ... export enum DefaultFieldNames { Id = 'Id', Content = 'Content', Description = 'Description', Completed = 'Completed', Due = 'Due Date', Link = 'Link', Project = 'Project', }

Every card integration must provide a fieldConfiguration object with the following members:

getAllFields - Given a data source already imported on this document by this card integration, return a list of all fields that could be eligible for import. The data source is provided because the list of fields available in case producing the list of available fields is easier with access to the imported data.

onSelectedFieldsChange - Optionally, you may provide a callback for code you want to run after the user adjusts which fields should be imported. This is usually an empty function call, as data will automatically be re-requested from your data connector if the list of requested fields changes. Not providing a value for this field will prevent users from configuring the fields shown in the card details panel:

/editorextensions/todoist/src/todoistcardintegration.ts
export class TodoistCardIntegration extends LucidCardIntegration { // ... public fieldConfiguration = { getAllFields: (dataSource: DataSourceProxy) => { return Promise.resolve([...Object.values(DefaultFieldNames)]); }, onSelectedFieldsChange: async (dataSource: DataSourceProxy, selectedFields: string[]) => {}, }; }

Card shape configuration

Lucid card integrations must provide the default configuration to use for the appearance of cards imported or created by the integration.

As with the getAllFields callback described above, the getDefaultConfig callback accepts a DataSourceProxy as a parameter, indicating which data connection the configuration is for, in case you need access to the imported data to determine the list of fields to display.

The following information can be provided:

  • cardConfig.fieldNames: an array of field names that should be displayed as text fields on the card shapes on-canvas
  • cardConfig.fieldStyles: optionally, text style overrides that should be applied for specific fields, on top of the default textStyle described in Basic configuration.
  • cardConfig.fieldDisplaySettings: information about fields that should be displayed as data graphics on card shapes
  • cardDetailsPanelConfig.fields: an array of fields that has been imported and should be shown in the card details panel, with a name and optionally a locked boolean indicating whether the field cannot be unchecked from the list of fields to import

The following example configures the Todoist Lucid card to display the data from the content field on the Todoist task as text. Furthermore, the ID and due date of the task will be displayed as badges on the card. The content, description and due date of the task will also be shown in the card details panel:

/editorextensions/todoist/src/todoistcardintegration.ts
export class TodoistCardIntegration extends LucidCardIntegration { // ... public getDefaultConfig = (dataSource: DataSourceProxy) => { return Promise.resolve({ cardConfig: { fieldNames: [DefaultFieldNames.Content], fieldDisplaySettings: new Map([ [ DefaultFieldNames.Id, { stencilConfig: { displayType: FieldDisplayType.SquareImageBadge, valueFormula: '="https://cdn-cashy-static-assets.lucidchart.com/extensibility/packages/todoist/Todoist-logo-for-card.svg"', onClickHandlerKey: OnClickHandlerKeys.OpenBrowserWindow, linkFormula: '=@Link', horizontalPosition: HorizontalBadgePos.RIGHT, tooltipFormula: '=IF(ISNOTEMPTY(LASTSYNCTIME), "Last synced " & RELATIVETIMEFORMAT(LASTSYNCTIME), "Open in Todoist")', backgroundColor: '#00000000', }, }, ], [ DefaultFieldNames.Due, { stencilConfig: { displayType: FieldDisplayType.DateBadge, tooltipFormula: '=@"Due Date"', }, }, ], ]), }, cardDetailsPanelConfig: { fields: [ { name: DefaultFieldNames.Content, locked: true, }, { name: DefaultFieldNames.Description, locked: true, }, { name: DefaultFieldNames.Due, locked: true, }, { name: DefaultFieldNames.Completed, locked: true, } ], }, }); }; }

Field display settings

For fields included in fieldDisplaySettings, a variety of display options are available by selecting a value for stencilConfig.displayType.

In each case, only the displayType is strictly required. The other options that you can set on these display configuration objects are as follows:

backgroundColor

For displayType values that support it, you may provide an override for the background color for this data. If this is not provided, the background color will default to the background color of the card itself, darkened 5%.

This may be provided as a literal color hex, e.g. "#00ff00ff" or as a formula by starting the string with "=", e.g. "=darken("#ffffff", 0.5)"

valueFormula

If specified, the result of this formula (executed in the context of the data item associated with this card) will be used instead of the raw field value when creating the data graphic. This can be useful if, for example, you want to convert an ID into a URL.

tooltipFormula

If specified, the result of this formula (executed in the context of the data item associated with this card) will be used as a tooltip when the user hovers the cursor over this data graphic.

horizontalPosition

Each display type has its default location on the card. You can override those default locations by setting these values.

verticalPosition

Each display type has its default location on the card. You can override those default locations by setting these values.

onClickHandlerKey

If specified, what behavior should happen when the user clicks on the data graphic generated via the above displayType?

linkFormula

If onClickHandlerKey is OpenBrowserWindow, this formula calculates the URL to open.

Create the import modal

Most Lucid card integrations will allow users to import data from their data source using the standard card import modal. To configure that modal for search and import, specify the importModal member of your LucidCardIntegration. You will provide three asynchronous callbacks that specify the set of search fields the user can fill out, perform the search based on those values, and finally import the data selected as new cards on-canvas. There is a fourth optional callback that can be used on the setup process.

In the /editorextensions/todoist/src/ directory, create a new file todoistimportmodal.ts. Here you will create a class that configures the import modal for the Todoist card integration:

/editorextensions/todoist/src/todoistimportmodal.ts
import { CollectionDefinition, CollectionProxy, EditorClient, ExtensionCardFieldDefinition, SerializedFieldType, } from 'lucid-extension-sdk'; export class TodoistImportModal { constructor(private readonly editorClient: EditorClient) {} // Stub these out for now public async getSearchFields( searchSoFar: Map<string, SerializedFieldType>, ): Promise<ExtensionCardFieldDefinition[]> { throw 'Not implemented'; } public async search(fields: Map<string, SerializedFieldType>): Promise<{ data: CollectionDefinition; fields: ExtensionCardFieldDefinition[]; partialImportMetadata: {collectionId: string; syncDataSourceId?: string}; }> { throw 'Not implemented'; } public async import( primaryKeys: string[], searchFields: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKeys: string[]}> { throw 'Not implemented'; } }

Now, construct this class and assign it to the import field of the TodoistCardIntegration:

/editorextensions/todoist/src/todoistcardintegration.ts
export class TodoistCardIntegration extends LucidCardIntegration { // ... public importModal = new TodoistImportModal(this.editorClient); }

Run the integration

This is a good time to check if everything is working as intended. In the root of you application, execute the following command to run your extension:

npx lucid-package test-editor-extension todoist

Now open a Lucidspark board and enable the Load local extensions option in the developer menu. Checking this option will automatically refresh the page and have the document attempt to load your locally running extension.

You should now see the Todoist integration show up in the submenu that is shown when you click on the more tools button (ellipses icon) located at the bottom of the left toolbar. Click the pin icon on the right to pin the Todoist integration to your toolbar.

You can now trigger the import modal. There's an error displayed because the search callback just throws an error.

Partial Import

Connecting to data

Now that you have the import modal showing up, you will need to fetch data from Todoist's OAuth 2.0 REST API to populate the import modal.

Configuring OAuth

The Lucid extensibility platform allows extensions to authorize with a third party using OAuth 2.0. To do so, you will first need to generate a unique identifier for your app.

Navigate to developer portal and create a new application. Once created, copy the app ID from the URL and paste it in the id field of your application's manifest.json file.

Copy Package ID

Now fill in the OAuth 2.0 provider details in the oauthProviders section of the manifest.json file. These provider details can be found in Todoist's API documentation:

manifest.json
{ // ... "oauthProviders": [ { "name": "todoist", "title": "Todoist", "authorizationUrl": "https://todoist.com/oauth/authorize", "tokenUrl": "https://todoist.com/oauth/access_token", "scopes": ["data:read_write"], "domainWhitelist": ["https://api.todoist.com", "https://todoist.com"], "clientAuthentication": "clientParameters" } ] }

Create the OAuth client

Before you can start making API requests to Todoist, you will also need to set up an OAuth 2.0 client in Todoist. Navigate to the Todoist developer console and create a new app.

Create Oauth

Once created, you will need to provide the OAuth redirect URL to Todoist. This is the URL users will be redirected to after granting your application access to their Todoist instance. Since Lucid handles OAuth 2.0 authentication for your app, a URL of the following form will need to be provided:

https://extensibility.lucid.app/packages/<applicationId>/oauthProviders/<oauthProviderName>/authorized

You will need to replace applicationId with the ID present in the id field of your manifest.json file and oauthProviderName with the name you gave your OAuth provider in the manifest.json file. For this guide, the oauthProviderName was set to be todoist.

Once you've entered the redirect URL in Todoist, click save settings.

Give your app the client credentials

Now that you have the client set up, you will need to give your app the appropriate credentials so that it can communicate with the Todoist client.

Create a new file <oauthProviderName>.credentials.local (for this guide, this file would be named todoist.credentials.local) in the root directory of your application (where the manifest.json file exists). Add a JSON object containing the OAuth client ID and client secret to this file. The ID and secret can be found in the app dashboard in Todoist:

todoist.credentials.local
{ "clientId": "Client ID for your OAuth provider", "clientSecret": "Client secret for your OAuth provider" }

Create the Todoist client

With OAuth configured you can now begin making requests to Todoist to populate the import modal.

In /editorextensions/todoist/src create a new folder named model and add a new file todoistmodel.ts to it. This file will contain interfaces representing the data you get from Todoist:

/editorextensions/todoist/src/model/todoistmodel.ts
export interface Project { id: string; name: string; comment_count: number; } export interface TodoistDate { date: string; is_recurring: boolean; datetime: string; string: string; timezone: string; } export interface Task { id: string; is_completed: boolean; content: string; description: string; due: TodoistDate; url: string; }

In /editorextensions/todoist/src create another folder named net and add a new file todoistclient.ts. This will contain a class that will be responsible for making the API requests to Todoist:

/editorextensions/todoist/src/net/todoistclient.ts
import {EditorClient, HumanReadableError, XHRResponse} from 'lucid-extension-sdk'; import { Project, Task } from '../model/todoistmodel'; export class TodoistClient { constructor(private readonly client: EditorClient) {} private readonly todoistOAuthProviderName = 'todoist'; private readonly baseUrl = 'https://api.todoist.com/rest/v2/' public async getProjects(): Promise<Project[]> { const rawResponse = await this.makeGetRequest(`${this.baseUrl}projects`); return this.parseAsAny(rawResponse) as Project[]; } public async getTasks(projectId: string, search?: string): Promise<Task[]> { const filterParam = search ? encodeURI(`&filter=search:${search}`) : ''; const rawResponse = await this.makeGetRequest(`${this.baseUrl}tasks?project_id=${projectId}${filterParam}`); return this.parseAsAny(rawResponse) as Task[]; } private async makeGetRequest(url: string) { try { const response = await this.client.oauthXhr(this.todoistOAuthProviderName, { url, method: 'GET', responseFormat: 'utf8', }); return response; } catch (error) { console.log('Error:', error); throw this.errorFromResponse(error); } } private errorFromResponse(response: any) { try { return new HumanReadableError(this.parseAsAny(response).errors[0].message); } catch (error) { return new Error(JSON.stringify(response)); } } private parseAsAny(data: XHRResponse): any { switch (data.responseFormat) { case 'utf8': return JSON.parse(data.responseText) as any; case 'binary': return JSON.parse(data.responseData as any) as any; } } }

Finally, create an instance of this client class in TodoistImportModal:

/editorextensions/todoist/src/todoistimportmodal.ts
export class TodoistImportModal { private todoistClient: TodoistClient; constructor(private readonly editorClient: EditorClient) { this.todoistClient = new TodoistClient(editorClient); } // ... }

Populate the import modal

You can now begin implementing the callbacks in the TodoistImportModal class.

getSearchFields callback

The getSearchFields callback returns a list of fields to display on the search modal, which the user selects to search or filter the available data. The callback takes all the entered search field values so far as a parameter, in case you want to return different search fields based on values entered in the form so far, e.g. to offer a dropdown to select a project based on the workspace already selected.

The following example displays a simple text search box, plus a dropdown to select what project they would like to see tasks from:

/editorextensions/todoist/src/todoistimportmodal.ts
export class TodoistImportModal { // ... private readonly searchField = 'search'; private readonly projectField = 'project'; public async getSearchFields( searchSoFar: Map<string, SerializedFieldType>, ): Promise<ExtensionCardFieldDefinition[]> { const projects = await this.todoistClient.getProjects(); const fields: ExtensionCardFieldDefinition[] = [ { name: this.searchField, label: 'Search', type: ScalarFieldTypeEnum.STRING, }, { name: this.projectField, label: 'Project', type: ScalarFieldTypeEnum.STRING, default: projects[0]?.id, constraints: [{type: FieldConstraintType.REQUIRED}], options: projects.map((project) => ({ label: project.name, value: project.id, })), }, ]; return fields; } // ... }
Supported search field types

While this integration will only be using the text field and a single select dropdown during import, Lucid supports several other types of user input fields.

The following examples are all currently-supported search field types:

Plain text input
{ name: 'search', label: 'Search', type: ScalarFieldTypeEnum.STRING, }
Simple single-select dropdown
{ name: 'complete', label: 'Status', //Note: May be NUMBER or STRING as well, depending on the "value" set on options below type: ScalarFieldTypeEnum.BOOLEAN, constraints: [{type: FieldConstraintType.MAX_VALUE, value: 1}], options: [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ], }
Simple multi-select dropdown
{ name: 'complete', label: 'Status', //Note: May be NUMBER or STRING as well, depending on the "value" set on options below type: ScalarFieldTypeEnum.BOOLEAN, options: [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ], }

Use the MAX_VALUE constraint to specify single-select or multi-select dropdown, as above.

Dropdown where options are loaded lazily
const optionsCallback = LucidCardIntegrationRegistry.registerFieldOptionsCallback(client, async () => { return [ {label: 'Completed', value: true}, {label: 'Not Completed', value: false}, ]; }); // ... { name: 'complete', label: 'Status', type: ScalarFieldTypeEnum.BOOLEAN, options: optionsCallback, }
Dropdown where options are queried as the user types a search term
const searchCallback = LucidCardIntegrationRegistry.registerFieldSearchCallback(client, async (searchText) => { return [ {label: 'Tabitha Ross', value:'Tabitha Ross'}, {label: 'Stanley Browning', value:'Stanley Browning'}, {label: 'Randall Lucas', value:'Randall Lucas'}, {label: 'Kira Ellis', value:'Kira Ellis'}, {label: 'Dale Bauer', value:'Dale Bauer'}, {label: 'Itzel Knight', value:'Itzel Knight'}, {label: 'Sage Beltran', value:'Sage Beltran'}, {label: 'Iris Ponce', value:'Iris Ponce'}, {label: 'Gisselle Conway', value:'Gisselle Conway'}, {label: 'Emely Williams', value:'Emely Williams'}, {label: 'Elena Arias', value:'Elena Arias'}, {label: 'Sarahi Aguirre', value:'Sarahi Aguirre'}, ].filter(one => one.label.includes(searchText)); }), // ... { name: 'owner', label: 'Owner', type: ScalarFieldTypeEnum.STRING, search: searchCallback, }

search callback

The next step is to implement the actual search functionality based on the values the user has entered into the search fields you specified above.

The search callback returns a CollectionDefinition including a schema and data items representing the search results. It also returns a list of fields to display as columns in the user-visible data table in the search modal.

In the example below, requests are being made to Todoist that search tasks belonging to the specified project. It then uses the response to populate the import modal:

/editorextensions/todoist/src/todoistimportmodal.ts
export class TodoistImportModal { // ... public async search(fields: Map<string, SerializedFieldType>): Promise<{ data: CollectionDefinition; fields: ExtensionCardFieldDefinition[]; partialImportMetadata: {collectionId: string; syncDataSourceId?: string}; }> { let search = fields.get(this.searchField); if (!isString(search)) { search = ''; } const projectId = fields.get(this.projectField) as string | undefined; const tasks = projectId ? await this.todoistClient.getTasks(projectId, search) : []; return { data: { schema: { fields: [ { name: DefaultFieldNames.Id, type: ScalarFieldTypeEnum.STRING, }, { name: DefaultFieldNames.Content, type: ScalarFieldTypeEnum.STRING, mapping: [SemanticFields.Title], }, { name: DefaultFieldNames.Completed, type: ScalarFieldTypeEnum.BOOLEAN, }, { name: DefaultFieldNames.Due, type: ScalarFieldTypeEnum.DATEONLY, mapping: [SemanticFields.Time], }, ], primaryKey: [DefaultFieldNames.Id], }, items: new Map( tasks.map((task) => [ JSON.stringify(task.id), { [DefaultFieldNames.Id]: task.id, [DefaultFieldNames.Content]: task.content, [DefaultFieldNames.Completed]: task.is_completed, [DefaultFieldNames.Due]: task.due ? {ms: Date.parse(task.due.date), isDateOnly: true} : undefined, }, ]), ), }, fields: [ { name: DefaultFieldNames.Content, label: DefaultFieldNames.Content, type: ScalarFieldTypeEnum.STRING, }, { name: DefaultFieldNames.Completed, label: DefaultFieldNames.Completed, type: ScalarFieldTypeEnum.BOOLEAN, }, { name: DefaultFieldNames.Due, label: 'Due', type: ScalarFieldTypeEnum.STRING, }, ], partialImportMetadata: { collectionId: CollectionName, syncDataSourceId: projectId, }, }; } // ... }

The optional partialImportMetadata that you can return from the search callback is used to prefill the Lucid task card with data while an import is being performed in the background.

onSetup callback

During the import modal setup, if you want to add any custom setup code, you can do by implementing this optional callback.

It is going to be called everytime right after the modal is displayed and before the first call to getSearchFields.

Run the integration

At this point you can run the integration to test whether your import modal is functioning properly. Make sure to add some tasks to your Todoist account!

Note: If you have the extension running locally you do not need to restart it when you make changes to the code. The local development server will refresh automatically when changes are detected. All you need to do is refresh the Lucidspark board to get the newest version of your extension.

Import Modal

Import data as Lucid cards

Now that you've allowed the user to find the tasks they want to import, you need to provide the callback that actually imports the selected tasks.

The import for this card integration will be performed with the help of a data-connector. A data-connector is run on its own server independent of the Lucid extension. The extension will make a request to the data connector to perform an import and pass in the required information. It is the data-connector's responsibility to fetch the data from the external source, convert it into a format Lucid understands and send a request to Lucid containing the data to be imported.

Note: To make the development experience easier, the Lucid sdk offers a debug server that can be used for local development. This guide will walk you through using the debug server. However, to make the integration publicly available, you will have to host the data-connector and provide a public URL to it in the manifest.

Create the data-connector

In the root of you application run the following command to create a new data-connector:

npx lucid-package create-data-connector todoist

This will create a new directory named dataconnectors containing a folder named todoist. It will also create an entry for the data-connector in the manifest.json file.

To provide the data-connector details to the extension, in the manifest.json file, fill in the fields in the dataConnectors section:

manifest.json
{ // ... "dataConnectors": [ { "name": "todoist", "oauthProviderName": "todoist", "callbackBaseUrl": "http://localhost:3001?kind=action&name=", "dataActions": { "Import": "Import" } } ] }

This declares a data-connector named todoist that uses the OAuth token for the todoist OAuth provider to make requests. You will be running this data connector on localhost:3001 which is why you set the callbackBaseUrl to be http://localhost:3001?kind=action&name=. The query parameters, kind and name, are used to inform the data-connector about the type of request being received. Finally, one of the actions this data connector will perform is Import.

Add the import data action name to /common/names.ts:

/common/names.ts
// ... export enum DataAction { Import = 'Import' }

Todoist provides a typescript sdk to communicate with their API. To use this sdk in you data-connector, execute the following command in /dataconnectors/todoist/:

npm install @doist/todoist-api-typescript --save-prod

Define the schema

You will need to convert the data you get from Todoist into a format compatible with Lucid. Begin by defining this Lucid compatible format.

In /dataconnectors/todoist create a new folder collections and within this folder create a new file taskcollections.ts. In this file, declare the schema for your task collection:

/dataconnectors/todoist/collections/taskcollections.ts
import {declareSchema, FieldConstraintType, ItemType, ScalarFieldTypeEnum, SemanticKind, SerializedLucidDateObject} from 'lucid-extension-sdk'; import {DefaultFieldNames} from '../../../common/names'; import {Task as TodoistTask, TodoistApi as TodoistClient, DueDate as TodoistDueDate} from '@doist/todoist-api-typescript'; export const taskSchema = declareSchema({ primaryKey: [DefaultFieldNames.Id], fields: { [DefaultFieldNames.Id]: { type: ScalarFieldTypeEnum.STRING, constraints: [{type: FieldConstraintType.LOCKED}], }, [DefaultFieldNames.Completed]: { type: ScalarFieldTypeEnum.BOOLEAN, mapping: [SemanticFields.Status], }, [DefaultFieldNames.Content]: { type: ScalarFieldTypeEnum.STRING, mapping: [SemanticFields.Title], }, [DefaultFieldNames.Description]: { type: ScalarFieldTypeEnum.STRING, mapping: [SemanticFields.Description], }, [DefaultFieldNames.Due]: { type: [ScalarFieldTypeEnum.DATE, ScalarFieldTypeEnum.DATEONLY, ScalarFieldTypeEnum.NULL] as const, mapping: [SemanticFields.Time], }, [DefaultFieldNames.Link]: { type: ScalarFieldTypeEnum.STRING, mapping: [SemanticFields.URL], constraints: [{type: FieldConstraintType.LOCKED}], }, [DefaultFieldNames.Project]: { type: ScalarFieldTypeEnum.STRING } }, }); export type TaskFieldsStructure = typeof taskSchema.example; export type TaskItemType = ItemType<TaskFieldsStructure>;

Also add helper functions that convert the data you get from Todoist into this format:

/dataconnectors/todoist/collections/taskcollections.ts
// ... export function getFormattedTask(task: TodoistTask): TaskItemType { return { [DefaultFieldNames.Id]: task.id, [DefaultFieldNames.Content]: task.content, [DefaultFieldNames.Description]: task.description, [DefaultFieldNames.Completed]: task.isCompleted, [DefaultFieldNames.Due]: task.due ? getFormattedDueDate(task.due) : null, [DefaultFieldNames.Link]: task.url, [DefaultFieldNames.Project]: task.projectId, }; } function getFormattedDueDate(dueDate: TodoistDueDate): SerializedLucidDateObject | null { const dateToUse = dueDate.datetime || dueDate.date; if (dateToUse) { return { ms: Date.parse(dateToUse) }; } return null; }

Create the import action

With the schema defined, you can now have the data-connector perform an import. In /dataconnectors/todoist/actions, add a new file import.ts:

/dataconnectors/todoist/actions/import.ts
import {TodoistApi as TodoistClient} from '@doist/todoist-api-typescript'; import {DataConnectorAsynchronousAction} from 'lucid-extension-sdk/dataconnector/actions/action'; import {CollectionName} from '../../../common/names'; import {getFormattedTask, taskSchema} from '../collections/taskcollections'; export const importAction: (action: DataConnectorAsynchronousAction) => Promise<{success: boolean}> = async ( action, ) => { // action.data contains data passed to the data-connector by the extension when an action is invoked. In this integration, // you will have the extension pass in the ID's of the tasks the user selected in the import modal. const taskIds = action.data as string[]; const todoistClient = new TodoistClient(action.context.userCredential); // Fetch the task data from Todoist const fullTaskData = await todoistClient.getTasks({ids: taskIds}); // Convert the data into a Lucid compatible format const formattedTaskData = fullTaskData.map(getFormattedTask); // Send the imported data to Lucid await action.client.update({ dataSourceName: 'Todoist', collections: { [CollectionName]: { schema: { fields: taskSchema.array, primaryKey: taskSchema.primaryKey.elements, }, patch: { items: taskSchema.fromItems(formattedTaskData), }, }, }, }); return {success: true}; };

Now, you can register the import action with the data-connector. Update /dataconnectors/todoist/index.ts with the following code:

/dataconnectors/todoist/index.ts
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {DataAction} from '../../common/names'; import {importAction} from './actions/import'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client).defineAsynchronousAction(DataAction.Import, importAction);

Run the debug server

In the dataconnectors/todoist folder execute the following commands to run the data-connector:

npx nodemon debug-server.ts

You should see the following output:

Routing / (Import) Listening on port 3001

Call the import action

With the data-connector setup, you should now be able to update the import callback in your extension to trigger the import action.

The import callback accepts as parameters a list of primary keys selected by the user from your CollectionDefinition returned from your search callback, as well as the set of field values entered by the user in the search form (in case you need that information in order to perform the import, e.g. the user selecting which server to query).

When the callback returns, the import should be complete, and the imported data should exist in a collection on the current document:

/editorextensions/todoist/src/todoistimportmodal.ts
export class TodoistImportModal { // ... public async import( primaryKeys: string[], searchFields: Map<string, SerializedFieldType>, ): Promise<{ collection: CollectionProxy; primaryKeys: string[]; }> { const projectId = searchFields.get(this.projectField); if (!isString(projectId) || !projectId) { throw new Error('No project selected'); } // Call the import action await this.editorClient.performDataAction({ actionName: DataAction.Import, syncDataSourceIdNonce: projectId, dataConnectorName: DataConnectorName, actionData: primaryKeys.map((pk) => JSON.parse(pk)), // pass in the ID's of the selected tasks to the data-connector asynchronous: true, }); // Wait for the import to complete const collection = await this.editorClient.awaitDataImport( DataConnectorName, projectId, CollectionName, primaryKeys, ); return {collection, primaryKeys}; } }
Call import action with custom import modal

If you decided to utilize the custom import modal, then you will still need to define your import callback. The goal is the same as the method for the standard import method, with the exception that you don't have access to search terms, as your iframe should have already selected which cards you would like to import.

/editorextensions/todoist/src/todoistimportmodal.ts
export class TodoistImportModal { // ... public async import( primaryKeys: string[], ): Promise<{ collection: CollectionProxy; primaryKeys: string[]; }> { const projectId = this.getProjectId(primaryKeys); // This method would utilize similar logic to the getSerachFields method. // Call the import action await this.editorClient.performDataAction({ actionName: DataAction.Import, syncDataSourceIdNonce: projectId, dataConnectorName: DataConnectorName, actionData: primaryKeys.map((pk) => JSON.parse(pk)), // pass in the ID's of the selected tasks to the data-connector asynchronous: true, }); // Wait for the import to complete const collection = await this.editorClient.awaitDataImport( DataConnectorName, projectId, CollectionName, primaryKeys, ); return {collection, primaryKeys}; } }

Perform an import

With both the editor-extension and data-connector running, you should be able to import Todoist tasks as Lucid cards.

Full Import

Fetching updates from Todoist

When linked to an external source, Lucid can update your card when changes are made outside of the document. This can be done via two different methods, hard refresh and polling, both of which are called outside of your extension when certain criteria are met.

Declaring the hard refresh and polling actions

Declare the hard refresh and polling actions by adding HardRefresh and Poll to your list of actions in the manifest.json file:

manifest.json
{ //... "dataConnectors": [ { "name": "todoist", "oauthProviderName": "todoist", "callbackBaseUrl": "http://localhost:3001?kind=action&name=", "dataActions": { "Import": "Import", "HardRefresh": "HardRefresh", "Poll": "Poll" } } ] }

Then add HardRefresh and Poll to the DataAction enumeration:

/common/names.ts
... export enum DataAction { Import = 'Import', HardRefresh = 'HardRefresh', Poll = 'Poll' }

Create the hard refresh action

A hard refresh allows your extension to refresh all of the data on a document when that document is opened for the first time after it has been closed by all users for more than 5 minutes. To add hard refresh support to your data connector, add a new file named hardrefresh.ts to the /dataconnectors/todoist/actions folder and fill it in with the following code:

/datasconnectors/todoist/actions/hardrefreh.ts
import {TodoistApi as TodoistClient} from '@doist/todoist-api-typescript'; import {DataConnectorAsynchronousAction} from 'lucid-extension-sdk'; import {isString} from 'lucid-extension-sdk/core/checks'; import {CollectionName} from '../../../common/names'; import {getFormattedTask, taskSchema} from '../collections/taskcollections'; export const hardRefreshAction: (action: DataConnectorAsynchronousAction) => Promise<{success: boolean}> = async ( action ) => { const todoistClient = new TodoistClient(action.context.userCredential); // Figure out which tasks you have already imported let taskIds: string[] = []; Object.keys(action.context.documentCollections).forEach((key) => { if (key.includes('Tasks')) { taskIds = taskIds.concat( action.context.documentCollections?.[key].map((taskId) => JSON.parse(taskId)).filter(isString), ); } }); // If no tasks were imported, then you don't need to refresh if (taskIds.length == 0) { return {success: true}; } // Update any tasks that you found const fullTaskData = await todoistClient.getTasks({ids: taskIds}); const formattedTaskData = fullTaskData.map(getFormattedTask); // Send the imported data to Lucid await action.client.update({ dataSourceName: 'Todoist', collections: { [CollectionName]: { schema: { fields: taskSchema.array, primaryKey: taskSchema.primaryKey.elements, }, patch: { items: taskSchema.fromItems(formattedTaskData), }, }, }, }); return {success: true}; };

Now, you can register the hard refresh action with the data connector. Update /dataconnectors/todoist/index.ts with the following code:

/dataconnectors/todoist/index.ts
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {DataAction} from '../../common/names'; import {importAction} from './actions/import'; import {hardRefreshAction} from './actions/hardrefresh'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client).defineAsynchronousAction(DataAction.Import, importAction).defineAsynchronousAction(DataAction.HardRefresh, hardRefreshAction);

Create the poll action

The poll action allows your extension to refresh all of the data on a document periodically while the document is open. In many cases, the poll action can utilize the same code as the hard refresh action:

/dataconnectors/todoist/index.ts
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {DataAction} from '../../common/names'; import {importAction} from './actions/import'; import {hardRefreshAction} from './actions/hardrefresh'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client).defineAsynchronousAction(DataAction.Import, importAction).defineAsynchronousAction(DataAction.HardRefresh, hardRefreshAction).defineAsynchronousAction(DataAction.Poll, hardRefreshAction);

Test hard refresh

With both the editor-extension and data-connector running, you should be able to test whether or not your card will refresh when opening the document. First, exit the document you have imported a task onto. Next, make some changes to the task you imported. Lastly, wait at least five minutes and open the document again. You should see the changes you made reflected in your imported card after a short period.

Test Hard Refresh

Test polling

With both the editor-extension and data-connector running, you should also be able to test whether or not your card is staying in sync through the polling action. While the document is open, make a change to your task in Todoist. Next, wait for around 30 seconds, and you should see your task update.

Test Polling

Pushing changes back to Todoist

When changes are made to Lucidcards linked to an external source, Lucid's services will automatically inform your data-connector of these changes. You can use this information to update the Task in the external data source.

Declaring the patch action

Declare the patch action by adding Patch to your list of actions in the manifest.json file:

manifest.json
{ //... "dataConnectors": [ { "name": "todoist", "oauthProviderName": "todoist", "callbackBaseUrl": "http://localhost:3001?kind=action&name=", "dataActions": { "Import": "Import", "Patch": "Patch" } } ] }

Then add Patch to the DataAction enumeration:

/common/names.ts
... export enum DataAction { Import = 'Import', Patch = 'Patch' }

Implementing the patch action

First, you will need to convert the data sent by Lucid, containing changes made to the cards, to a format that can be sent to Todoist to update the task. Add the following functions to taskcolllections.ts:

/dataconnectors/todoist/collections/taskcollections.ts
import {UpdateTaskArgs as TodoistUpdateTaskArgs} from "@doist/todoist-api-typescript"; ... export function lucidPatchToTodoistPatch( data: Partial<TaskItemType> ): TodoistUpdateTaskArgs { const dueDate = data[DefaultFieldNames.Due] as | SerializedLucidDateObject | null | undefined; return { content: data[DefaultFieldNames.Content], description: data[DefaultFieldNames.Description], dueDatetime: dueDate ? lucidDateToTodoistDate(dueDate) : undefined, }; } function lucidDateToTodoistDate(date: SerializedLucidDateObject): string { if ('ms' in date) { return new Date(date.ms).toISOString(); } return new Date(date.isoDate).toISOString(); }

Now, in the actions folder, create a new file named patch.ts. This file will be responsible for handling the request containing the changes sent by Lucid. The following code parses the changes and updates the corresponding tasks in Todoist:

/dataconnectors/todoist/actions/patch.ts
import {TodoistApi as TodoistClient} from '@doist/todoist-api-typescript'; import {DataConnectorPatchAction, PatchItems} from 'lucid-extension-sdk/dataconnector/actions/action'; import {PatchChange} from 'lucid-extension-sdk/dataconnector/actions/patchresponsebody'; import {DefaultFieldNames} from '../../../common/names'; import {lucidPatchToTodoistPatch, TaskItemType} from '../collections/taskcollections'; export const patchAction: (action: DataConnectorPatchAction) => Promise<PatchChange[]> = async (action) => { const todoistClient = new TodoistClient(action.context.userCredential); return await Promise.all( action.patches.map(async (patch) => { const change = patch.getChange(); await Promise.all([updateTodoistTasks(patch.itemsChanged, change, todoistClient)]); return change; }), ); }; async function updateTodoistTasks(itemsChanged: PatchItems, change: PatchChange, todoistClient: TodoistClient) { await Promise.all(Object.entries(itemsChanged).map(async ([primaryKey, updates]) => { try { const taskId = JSON.parse(primaryKey) as string; if (taskIsCompleted(updates)) { await todoistClient.closeTask(taskId); } else if (taskShouldBeReOpened(updates)) { await todoistClient.reopenTask(taskId); } const todoistParams = await lucidPatchToTodoistPatch(updates); if (Object.keys(todoistParams).length > 0) { await todoistClient.updateTask(taskId, todoistParams); } } catch (err) { change.setTooltipError(primaryKey, 'Failed to update Todoist'); console.error('error patching', err); } })); } function taskIsCompleted(data: Partial<TaskItemType>): boolean | undefined { return data[DefaultFieldNames.Completed]; } function taskShouldBeReOpened(data: Partial<TaskItemType>): boolean { return data[DefaultFieldNames.Completed] === false; }

Now, you can register the patch action with the data-connector. Update /data-connector/index.ts with the following code:

/dataconnectors/todoist/index.ts
import { DataConnector, DataConnectorClient } from "lucid-extension-sdk"; import { importAction } from "./actions/import"; import { DataAction } from "../../common/names"; import { patchAction } from "./actions/patch"; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client) .defineAsynchronousAction(DataAction.Import, importAction) .defineAction(DataAction.Patch, patchAction);

Perform an update

With both the editor-extension and data-connector running, you should be able to make edits to the Lucid cards and have the corresponding task in Todoist be updated.

Update Card

Creating new tasks in Todoist

The patch action created above can also be used to create new tasks in the external data source. The patch Lucid sends to your data-connector will contain information about any new data-linked cards the user created. You can use this data to create a new task in Todoist.

Updating the patch action

The data-connector will need to convert the creation data it receives from Lucid into data that can be sent to Todoist to create the task. In taskcollections.ts add the following function:

/dataconnectors/todoist/collections/taskcollections.ts
import {AddTaskArgs as TodoistAddTaskArgs} from "@doist/todoist-api-typescript"; ... export function lucidPatchToTodoistCreationData(data: Partial<TaskItemType>): TodoistAddTaskArgs { const {content, description, dueDatetime} = lucidPatchToTodoistPatch(data); return { content: content ?? '', // Todoist requires this to be defined dueDatetime: dueDatetime ?? undefined, // The rest of these need to be undefined instead of null for creation description: description, }; }

Now, you need to add a function which creates Todoist tasks to patch.ts:

/dataconnectors/todoist/actions/patch.ts
... async function createTodoistTasks( itemsAdded: PatchItems, syncCollectionId: string, change: PatchChange, todoistClient: TodoistClient, ) { await Promise.all(Object.entries(itemsAdded).map(async ([oldPrimaryKey, additions]) => { try { const formattedCreate = { ...lucidPatchToTodoistCreationData(additions), }; const taskResponse = await todoistClient.addTask(formattedCreate); change.collections.push({ collectionId: syncCollectionId, items: {[oldPrimaryKey]: getFormattedTask(taskResponse)}, itemsDeleted: [], }); } catch (err) { change.setTooltipError(oldPrimaryKey, 'Failed to create task in Todoist'); console.error('Error creating item', err); } })); }

Finally, update the patchAction function in this file to call the task creation function you added:

/dataconnectors/todoist/actions/patch.ts
... export const patchAction: (action: DataConnectorPatchAction) => Promise<PatchChange[]> = async (action) => { const todoistClient = new TodoistClient(action.context.userCredential); return await Promise.all( action.patches.map(async (patch) => { const change = patch.getChange(); await Promise.all([ updateTodoistTasks(patch.itemsChanged, change, todoistClient), createTodoistTasks(patch.itemsAdded, patch.syncCollectionId, change, todoistClient), ]); return change; }), ); }; ...

Creating the card creation callout

With the data-connector set up, you can now implement an entry point for the user to input information to create a new card. In /editorextensions/todoist/src, create a new file named todoisttaskcreator.ts:

/editorextensions/todoist/src/todoisttaskcreator.ts
export class TodoistTaskCreator { private todoistClient: TodoistClient; constructor(private readonly editorClient: EditorClient) { this.todoistClient = new TodoistClient(editorClient); } }

First, you will need to define all the fields that should be shown to the user when they attempt to create a new Todoist card. The getInputFields callback handles this responsibility. The example below requires the user to provide values for the content and project field to create a new task:

/editorextensions/todoist/src/todoisttaskcreator.ts
export class TodoistTaskCreator { ... private readonly contentField = "content"; private readonly projectField = "project"; public async getInputFields(inputSoFar: Map<string, SerializedFieldType>): Promise<ExtensionCardFieldDefinition[]> { const projects = await this.todoistClient.getProjects(); const fields: ExtensionCardFieldDefinition[] = [ { name: this.contentField, label: 'Content', type: ScalarFieldTypeEnum.STRING, constraints: [{type: FieldConstraintType.REQUIRED}], }, { name: this.projectField, label: 'Project', type: ScalarFieldTypeEnum.STRING, default: projects[0]?.id, constraints: [{type: FieldConstraintType.REQUIRED}], options: projects.map((project) => ({label: project.name, value: project.id})), }, ]; return fields; } }

Once the user provides the information you will need to pass this information off to the data-connector so that a new task can be created in Todoist. Since updates in Lucid are automatically sent to the data-connector, all you will need to do is update the task collection with the new data. This is done in the createCardData callback:

/editorextensions/todoist/src/todoisttaskcreator.ts
export class TodoistTaskCreator { ... public async createCardData( input: Map<string, SerializedFieldType>, ): Promise<{collection: CollectionProxy; primaryKey: string}> { const projectId = input.get(this.projectField); if (!isString(projectId) || !projectId) { throw new Error('No project selected'); } let collection: CollectionProxy; try { // Check if the Tasks collection has already been created in the // data source, with a 1ms timeout. If the collection doesn't exist yet, // an exception will be thrown. collection = await this.editorClient.awaitDataImport(DataConnectorName, projectId, CollectionName, [], 1); } catch { // No collection exists yet. Ask the data connector to perform an import of // an empty list of tasks, just to get the collection created along with whatever other // metadata you might need to set up there. await this.editorClient.performDataAction({ dataConnectorName: DataConnectorName, syncDataSourceIdNonce: projectId, actionName: DataAction.Import, actionData: [], asynchronous: true, }); // And now wait for that empty import to complete, getting a reference to the // resulting collection. collection = await this.editorClient.awaitDataImport(DataConnectorName, projectId, CollectionName, []); } // Add the user input data to the collection. These changes will be sent to // the data-connector const primaryKeys = collection.patchItems({ added: [ { [DefaultFieldNames.Project] : projectId, [DefaultFieldNames.Content]: input.get(this.contentField), }, ], }); if (primaryKeys.length != 1) { throw new Error('Failed to add new card data'); } return {collection, primaryKey: primaryKeys[0]}; } }

Finally, construct an object of this class in TodoistCardIntegration:

/editorextensions/todoist/src/todoistcardintegration.ts
export class TodoistCardIntegration extends LucidCardIntegration { // ... public addCard = new TodoistTaskCreator(this.editorClient); }

Create a new task

With both the editor-extension and data-connector running, you should be able to create new Lucid cards along with the corresponding task in Todoist.

Create Task

Examples

The source code for a basic card integration not linked to any third party can be found here. This can serve as a template for you to build a card integration.

For a more thorough example, you can look at the source code for Lucid's Asana Cards extension here. You can find the extension here if you want to try it out.