├── .gitignore ├── block.json ├── .block └── remote.json ├── frontend ├── loadCSS.js ├── layout.js ├── settings.js ├── index.js └── SettingsForm.js ├── README.md ├── package.json ├── .eslintrc.js └── LICENSE.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.airtableblocksrc.json 3 | /build 4 | -------------------------------------------------------------------------------- /block.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "frontendEntry": "./frontend/index.js" 4 | } 5 | -------------------------------------------------------------------------------- /.block/remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockId": "blkqUrtsb39vAM1FH", 3 | "baseId": "appxkZrQHE4sj9D1w" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/loadCSS.js: -------------------------------------------------------------------------------- 1 | import {loadCSSFromString} from '@airtable/blocks/ui'; 2 | 3 | const cssString = ` 4 | span.prompt { 5 | padding: 2rem; 6 | margin: 0; 7 | font-size: 17px; 8 | font-weight: 500; 9 | color: hsl(0, 0%, 46%); 10 | line-height: 1.5; 11 | text-align: center; 12 | } 13 | 14 | g.node { 15 | cursor: pointer; 16 | user-select: none; 17 | } 18 | `; 19 | 20 | function loadCSS() { 21 | loadCSSFromString(cssString); 22 | } 23 | 24 | export default loadCSS; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flowchart extension 2 | 3 | Visualize linked records as a directed graph, such as a flowchart, decision tree, or user flow diagram. Export your 4 | graph to SVG for further editing or PNG for sharing. 5 | 6 | ## How to remix this extension 7 | 8 | 1. Create a new base (or you can use an existing base). 9 | 10 | 2. Create a new extension in your base (see [Create a new extension](https://airtable.com/developers/blocks/guides/hello-world-tutorial#create-a-new-app)), 11 | selecting "Remix from Github" as your template. 12 | 13 | 3. From the root of your new extension, run `block run`. 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@airtable/flowchart-app", 3 | "version": "2.0.0", 4 | "scripts": { 5 | "lint": "ESLINT_USE_FLAT_CONFIG=false eslint frontend" 6 | }, 7 | "dependencies": { 8 | "@airtable/blocks": "latest", 9 | "prop-types": "^15.7.2", 10 | "react": "^16.14.0", 11 | "react-dom": "^16.14.0", 12 | "save-svg-as-png": "^1.4.14", 13 | "workerize": "^0.1.7" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^9.5.0", 17 | "eslint-plugin-react": "^7.34.2", 18 | "eslint-plugin-react-hooks": "^4.6.2" 19 | }, 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:react/recommended'], 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | }, 18 | plugins: ['react', 'react-hooks'], 19 | rules: { 20 | 'react/prop-types': 0, 21 | 'react-hooks/rules-of-hooks': 'error', 22 | 'react-hooks/exhaustive-deps': 'warn', 23 | }, 24 | settings: { 25 | react: { 26 | version: 'detect', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Airtable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /frontend/layout.js: -------------------------------------------------------------------------------- 1 | import workerize from 'workerize'; 2 | import {colorUtils} from '@airtable/blocks/ui'; 3 | 4 | import {LinkStyle, ChartOrientation, RecordShape} from './settings'; 5 | 6 | const DEBUG_OUTPUT = false; // Set to true to log layout source string to console 7 | let worker; 8 | 9 | const workerString = ` 10 | // Run viz.js in a worker to avoid blocking the main thread. 11 | self.importScripts('https://cdn.jsdelivr.net/npm/@viz-js/viz@3.1.0'); 12 | 13 | export function layout(source) { 14 | return new Promise((resolve, reject) => { 15 | let timeoutTimer = setTimeout(() => { 16 | reject(new Error('timeout')); 17 | }, 10000); 18 | 19 | Viz.instance().then(viz => { 20 | return viz.renderString(source, { 21 | format: 'svg', 22 | engine: 'dot', 23 | }); 24 | }).then(svgString => { 25 | resolve(svgString); 26 | clearTimeout(timeoutTimer); 27 | }).catch(err => { 28 | clearTimeout(timeoutTimer); 29 | reject(err); 30 | }); 31 | }); 32 | } 33 | `; 34 | 35 | /** 36 | * Creates a string representation of the graph based on the passed in settings 37 | * Uses the viz worker to convert this string to an svg 38 | * See https://www.graphviz.org/documentation/ for more details. 39 | * @param settings 40 | * @returns {Promise} The returned promise should resolve to an svg 41 | */ 42 | export function createLayout(settings) { 43 | if (!worker) { 44 | worker = workerize(workerString); 45 | } 46 | const {chartOrientation, linkStyle, recordShape, queryResult, field} = settings; 47 | let source = 'digraph {\n\t'; 48 | source += 'bgcolor=transparent\n\t'; 49 | source += 'pad=0.25\n\t'; 50 | source += 'nodesep=0.75\n\t'; 51 | 52 | if (chartOrientation === ChartOrientation.HORIZONTAL) { 53 | source += 'rankdir=LR\n\t'; 54 | } 55 | 56 | switch (linkStyle) { 57 | case LinkStyle.STRAIGHT_LINES: 58 | source += 'splines=line\n\n\t'; 59 | break; 60 | case LinkStyle.CURVED_LINES: 61 | source += 'splines=curved\n\n\t'; 62 | break; 63 | case LinkStyle.RIGHT_ANGLES: 64 | default: 65 | source += 'splines=ortho\n\n\t'; 66 | break; 67 | } 68 | 69 | source += 'node [\n\t\t'; 70 | switch (recordShape) { 71 | case RecordShape.ELLIPSE: 72 | source += 'shape=ellipse\n\t\t'; 73 | break; 74 | case RecordShape.CIRCLE: 75 | source += 'shape=circle\n\t\t'; 76 | break; 77 | case RecordShape.DIAMOND: 78 | source += 'shape=diamond\n\t\t'; 79 | break; 80 | case RecordShape.ROUNDED: 81 | case RecordShape.RECTANGLE: 82 | default: 83 | source += 'shape=rect\n\t\t'; 84 | break; 85 | } 86 | source += `style="filled${recordShape === RecordShape.ROUNDED ? ',rounded' : ''}"\n\t\t`; 87 | source += 'fontname=Helvetica\n\t'; 88 | source += ']\n\n\t'; 89 | 90 | const nodes = []; 91 | const edges = []; 92 | for (const record of queryResult.records) { 93 | if (record.isDeleted) { 94 | continue; 95 | } 96 | const recordColor = queryResult.getRecordColor(record); 97 | const shouldUseLightText = record 98 | ? colorUtils.shouldUseLightTextOnColor(recordColor) 99 | : false; 100 | let displayText = record.name 101 | .substring(0, 50) 102 | .trim() 103 | .replace(/"/g, '\\"'); 104 | if (record.name.length > 50) { 105 | displayText += '...'; 106 | } 107 | nodes.push( 108 | `${record.id} [id="${record.id}" label="${displayText}" 109 | tooltip="${displayText}" 110 | fontcolor="${shouldUseLightText ? 'white' : 'black'}" 111 | fillcolor="${recordColor ? colorUtils.getHexForColor(recordColor) : 'white'}"]`, 112 | ); 113 | 114 | const linkedRecordCellValues = record.getCellValue(field.id) || []; 115 | for (const linkedRecordCellValue of linkedRecordCellValues) { 116 | // The record might be in the cell value but not in the query result when it is deleted 117 | const linkedRecord = queryResult.getRecordByIdIfExists(linkedRecordCellValue.id); 118 | if (!linkedRecord || linkedRecord.isDeleted) { 119 | continue; 120 | } 121 | edges.push( 122 | `${record.id} -> ${linkedRecord.id} [id="${record.id}->${linkedRecord.id}"]`, 123 | ); 124 | } 125 | } 126 | 127 | source += nodes.join('\n\t'); 128 | source += '\n\n\t'; 129 | source += edges.join('\n\t'); 130 | source += '\n}'; 131 | 132 | if (DEBUG_OUTPUT) { 133 | console.log(source); 134 | } 135 | return worker.layout(source); 136 | } 137 | -------------------------------------------------------------------------------- /frontend/settings.js: -------------------------------------------------------------------------------- 1 | import {FieldType} from '@airtable/blocks/models'; 2 | import {useBase, useGlobalConfig} from '@airtable/blocks/ui'; 3 | 4 | export const ConfigKeys = { 5 | TABLE_ID: 'tableId', 6 | VIEW_ID: 'viewId', 7 | FIELD_ID: 'fieldId', 8 | CHART_ORIENTATION: 'chartOrientation', 9 | LINK_STYLE: 'linkStyle', 10 | RECORD_SHAPE: 'recordShape', 11 | }; 12 | 13 | export const allowedFieldTypes = [FieldType.MULTIPLE_RECORD_LINKS]; 14 | 15 | export const RecordShape = Object.freeze({ 16 | ROUNDED: 'rounded', 17 | RECTANGLE: 'rectangle', 18 | ELLIPSE: 'ellipse', 19 | CIRCLE: 'circle', 20 | DIAMOND: 'diamond', 21 | }); 22 | 23 | export const LinkStyle = Object.freeze({ 24 | RIGHT_ANGLES: 'rightAngles', 25 | STRAIGHT_LINES: 'straightLines', 26 | }); 27 | 28 | export const ChartOrientation = Object.freeze({ 29 | HORIZONTAL: 'horizontal', 30 | VERTICAL: 'vertical', 31 | }); 32 | 33 | const defaults = Object.freeze({ 34 | [ConfigKeys.CHART_ORIENTATION]: ChartOrientation.VERTICAL, 35 | [ConfigKeys.LINK_STYLE]: LinkStyle.RIGHT_ANGLES, 36 | [ConfigKeys.RECORD_SHAPE]: RecordShape.ROUNDED, 37 | }); 38 | 39 | /** 40 | * Reads the values stored in GlobalConfig and inserts defaults for missing values 41 | * @param {GlobalConfig} globalConfig 42 | * @returns {{ 43 | * tableId?: string, 44 | * viewId?: string, 45 | * fieldId?: string, 46 | * chartOrientation: ChartOrientation, 47 | * linkStyle: LinkStyle, 48 | * recordShape: RecordShape, 49 | * }} 50 | */ 51 | function getRawSettingsWithDefaults(globalConfig) { 52 | const rawSettings = {}; 53 | for (const globalConfigKey of Object.values(ConfigKeys)) { 54 | const storedValue = globalConfig.get(globalConfigKey); 55 | if ( 56 | storedValue === undefined && 57 | Object.prototype.hasOwnProperty.call(defaults, globalConfigKey) 58 | ) { 59 | rawSettings[globalConfigKey] = defaults[globalConfigKey]; 60 | } else { 61 | rawSettings[globalConfigKey] = storedValue; 62 | } 63 | } 64 | 65 | return rawSettings; 66 | } 67 | 68 | /** 69 | * Takes values read from GlobalConfig and converts them to Airtable objects where possible. 70 | * Also creates an extra key for queryResult which is derived from view and field. 71 | * @param {object} rawSettings - The object returned by getRawSettingsWithDefaults 72 | * @param {Base} base - The base being used by the extension in order to convert id's to objects 73 | * @returns {{ 74 | * table: Table | null, 75 | * view: View | null, 76 | * field: Field | null, 77 | * queryResult: RecordQueryResult | null, 78 | * chartOrientation: ChartOrientation, 79 | * linkStyle: LinkStyle, 80 | * recordShape: RecordShape, 81 | * }} 82 | */ 83 | function getSettings(rawSettings, base) { 84 | const table = base.getTableByIdIfExists(rawSettings.tableId); 85 | const view = table ? table.getViewByIdIfExists(rawSettings.viewId) : null; 86 | const field = table ? table.getFieldByIdIfExists(rawSettings.fieldId) : null; 87 | const queryResult = 88 | view && field ? view.selectRecords({fields: [table.primaryField, field]}) : null; 89 | return { 90 | table, 91 | view, 92 | field, 93 | queryResult, 94 | chartOrientation: rawSettings.chartOrientation, 95 | linkStyle: rawSettings.linkStyle, 96 | recordShape: rawSettings.recordShape, 97 | }; 98 | } 99 | 100 | /** 101 | * Wraps the settings with validation information 102 | * @param {object} settings - The object returned by getSettings 103 | * @returns {{settings: object, isValid: boolean} | {settings: object, isValid: boolean, message: string}} 104 | */ 105 | function getSettingsValidationResult(settings) { 106 | const {queryResult, table, field} = settings; 107 | if (!queryResult) { 108 | return { 109 | isValid: false, 110 | message: 'Pick a table, view, and linked record field', 111 | settings: settings, 112 | }; 113 | } else if (field.type !== FieldType.MULTIPLE_RECORD_LINKS) { 114 | return { 115 | isValid: false, 116 | message: 'Select a linked record field', 117 | settings: settings, 118 | }; 119 | } else if (field.options.linkedTableId !== table.id) { 120 | return { 121 | isValid: false, 122 | message: 'Linked record field must be linked to same table', 123 | settings: settings, 124 | }; 125 | } 126 | return { 127 | isValid: true, 128 | settings: settings, 129 | }; 130 | } 131 | 132 | /** 133 | * A React hook to validate and access settings configured in SettingsForm. 134 | * @returns {{settings: object, isValid: boolean, message: string} | {settings: object, isValid: boolean}} 135 | */ 136 | export function useSettings() { 137 | const base = useBase(); 138 | const globalConfig = useGlobalConfig(); 139 | const rawSettings = getRawSettingsWithDefaults(globalConfig); 140 | const settings = getSettings(rawSettings, base); 141 | return getSettingsValidationResult(settings); 142 | } 143 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from 'react'; 2 | import {saveSvgAsPng, svgAsDataUri} from 'save-svg-as-png'; 3 | import { 4 | Box, 5 | expandRecord, 6 | initializeBlock, 7 | Loader, 8 | useLoadable, 9 | useSettingsButton, 10 | useViewport, 11 | useWatchable, 12 | } from '@airtable/blocks/ui'; 13 | 14 | import {createLayout} from './layout'; 15 | import loadCSS from './loadCSS'; 16 | import {useSettings} from './settings'; 17 | import SettingsForm from './SettingsForm'; 18 | 19 | // viz.js has a stack overflow when there are too many records. So add a limit to be safe. 20 | const MAX_RECORDS = 100; 21 | export const ExportType = Object.freeze({ 22 | PNG: 'png', 23 | SVG: 'svg', 24 | }); 25 | 26 | loadCSS(); 27 | 28 | const domParser = new DOMParser(); 29 | 30 | function FlowchartExtension() { 31 | const viewport = useViewport(); 32 | const [isSettingsVisible, setIsSettingsVisible] = useState(false); 33 | useSettingsButton(() => { 34 | if (!isSettingsVisible) { 35 | viewport.enterFullscreenIfPossible(); 36 | } 37 | setIsSettingsVisible(!isSettingsVisible); 38 | }); 39 | const settingsValidationResult = useSettings(); 40 | const {queryResult} = settingsValidationResult.settings; 41 | useLoadable(queryResult); 42 | useWatchable(queryResult, ['records', 'cellValues', 'recordColors']); 43 | 44 | useEffect(() => { 45 | if (!settingsValidationResult.isValid) { 46 | setIsSettingsVisible(true); 47 | } 48 | }, [settingsValidationResult.isValid]); 49 | 50 | const graph = useRef(null); 51 | 52 | function draw() { 53 | if (!graph.current) { 54 | // Return early if ref isn't ready yet. 55 | return; 56 | } 57 | if (!settingsValidationResult.isValid) { 58 | graph.current.innerHTML = `${settingsValidationResult.message}`; 59 | return; 60 | } 61 | 62 | if (!queryResult.isDataLoaded) { 63 | graph.current.innerHTML = 'Loading...'; 64 | } else if (queryResult.records.length === 0) { 65 | graph.current.innerHTML = 'Add some records to get started'; 66 | } else if (queryResult.records.length > MAX_RECORDS) { 67 | graph.current.innerHTML = ` 68 | The flowchart extension can only visualize up to ${MAX_RECORDS} records. Try deleting some records or 69 | filtering them out of the view. 70 | `; 71 | } else { 72 | createLayout(settingsValidationResult.settings).then(svg => { 73 | const svgDocument = domParser.parseFromString(svg, 'image/svg+xml'); 74 | const svgElement = svgDocument.firstElementChild; 75 | if (svgElement && graph.current) { 76 | // Set the width and height of the SVG element so that it takes up the full dimensions of the 77 | // extension frame. 78 | const width = svgElement.getAttribute('width'); 79 | const height = svgElement.getAttribute('height'); 80 | if (Number(width) > Number(height)) { 81 | svgElement.setAttribute('width', '100%'); 82 | svgElement.removeAttribute('height'); 83 | } else { 84 | svgElement.setAttribute('height', '100%'); 85 | svgElement.removeAttribute('width'); 86 | } 87 | graph.current.innerHTML = ''; 88 | graph.current.appendChild(svgElement); 89 | } 90 | }); 91 | } 92 | } 93 | 94 | function _onGraphClick(e) { 95 | if (!queryResult || !queryResult.isDataLoaded) { 96 | return; 97 | } 98 | let target = e.target || null; 99 | // Traverse up the element tree from the click event target until we find an svg element 100 | // describing a 'node' that has a corresponding record that we can expand. 101 | while (target && target !== graph.current) { 102 | if (target.classList.contains('node')) { 103 | const record = queryResult.getRecordByIdIfExists(target.id); 104 | if (record) { 105 | expandRecord(record); 106 | return; 107 | } 108 | } 109 | target = target.parentElement; 110 | } 111 | } 112 | 113 | function _onExportGraph(exportType) { 114 | const {view} = settingsValidationResult.settings; 115 | if (view && graph.current) { 116 | const svgElement = graph.current.firstElementChild; 117 | if (svgElement) { 118 | if (exportType === ExportType.PNG) { 119 | saveSvgAsPng(svgElement, `${view.name}.png`, { 120 | scale: 2.0, 121 | }); 122 | } else if (exportType === ExportType.SVG) { 123 | // Convert the SVG to a data URI and download it via an anchor link. 124 | svgAsDataUri(svgElement, {}, uri => { 125 | const downloadLink = document.createElement('a'); 126 | downloadLink.download = `${view.name}.svg`; 127 | downloadLink.href = uri; 128 | document.body.appendChild(downloadLink); 129 | downloadLink.click(); 130 | document.body.removeChild(downloadLink); 131 | }); 132 | } else { 133 | throw new Error('Unexpected export type: ', exportType); 134 | } 135 | } 136 | } 137 | } 138 | 139 | draw(); 140 | return ( 141 | 151 |
164 | 165 |
166 | {isSettingsVisible && ( 167 | 172 | )} 173 |
174 | ); 175 | } 176 | 177 | initializeBlock(() => ); 178 | -------------------------------------------------------------------------------- /frontend/SettingsForm.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, {Fragment} from 'react'; 3 | import {Field, RecordQueryResult, Table, View} from '@airtable/blocks/models'; 4 | import { 5 | Box, 6 | Button, 7 | FieldPickerSynced, 8 | FormField, 9 | Heading, 10 | Label, 11 | Link, 12 | SelectButtonsSynced, 13 | SelectSynced, 14 | TablePickerSynced, 15 | Text, 16 | ViewPickerSynced, 17 | } from '@airtable/blocks/ui'; 18 | 19 | import {ExportType} from './index'; 20 | import {allowedFieldTypes, ConfigKeys, LinkStyle, ChartOrientation, RecordShape} from './settings'; 21 | 22 | function SettingsForm({setIsSettingsVisible, settingsValidationResult, onExportGraph}) { 23 | const {settings, isValid} = settingsValidationResult; 24 | return ( 25 | 32 | 40 | Settings 41 | 42 | 43 | 44 | {settings.table && ( 45 | 46 | 47 | 51 | 52 | 56 | 61 | 62 | 70 | 77 | 78 | 79 | 86 | 87 | 88 | 99 | 100 | 101 | 102 | Record color 103 | 104 | 105 | Record coloring is{' '} 106 | 110 | based on the view 111 | 112 | 113 | 114 | 115 | )} 116 | 117 | 125 | 126 | 129 | 136 | 139 | 140 | 143 | 144 | 145 | ); 146 | } 147 | 148 | SettingsForm.propTypes = { 149 | setIsSettingsVisible: PropTypes.func.isRequired, 150 | onExportGraph: PropTypes.func.isRequired, 151 | settingsValidationResult: PropTypes.shape({ 152 | settings: PropTypes.shape({ 153 | table: PropTypes.instanceOf(Table), 154 | view: PropTypes.instanceOf(View), 155 | field: PropTypes.instanceOf(Field), 156 | queryResult: PropTypes.instanceOf(RecordQueryResult), 157 | chartOrientation: PropTypes.oneOf(Object.values(ChartOrientation)).isRequired, 158 | linkStyle: PropTypes.oneOf(Object.values(LinkStyle)).isRequired, 159 | recordShape: PropTypes.oneOf(Object.values(RecordShape)).isRequired, 160 | }).isRequired, 161 | isValid: PropTypes.bool.isRequired, 162 | }), 163 | }; 164 | 165 | export default SettingsForm; 166 | --------------------------------------------------------------------------------