├── .block └── remote.json ├── .eslintrc.js ├── .gitignore ├── LICENSE.md ├── README.md ├── block.json ├── frontend ├── SettingsForm.js ├── index.js └── settings.js ├── media └── block.gif ├── package-lock.json └── package.json /.block/remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockId": "blkNeu8QsnzGn97kw", 3 | "baseId": "appm25RDUu36zpdKP" 4 | } 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.airtableblocksrc.json 3 | /build -------------------------------------------------------------------------------- /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 6 | associated documentation files (the "Software"), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 15 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 17 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URL preview app 2 | 3 | When the user selects a record in grid view, this app gets a preview URL from the record and shows 4 | the corresponding preview. This app supports previews from the following services: YouTube, Vimeo, 5 | Spotify, Soundcloud, and Figma. 6 | 7 | The code shows: 8 | 9 | - How to use the Cursor API to detect when a user has selected a record in grid view, and how to 10 | get the selected record. 11 | 12 | - How to embed content in a app. 13 | 14 | ## How to run this app 15 | 16 | 1. Create a new base (or you can use an existing base). 17 | 18 | 2. Create a new app in your new base (see 19 | [Create a new app](https://airtable.com/developers/blocks/guides/hello-world-tutorial#create-a-new-app)), 20 | selecting "URL preview" as your template. 21 | 22 | 3. From the root of your new app, run `block run`. 23 | 24 | ## See the app running 25 | 26 | ![App showing YouTube video when user selects record in grid view](media/block.gif) 27 | -------------------------------------------------------------------------------- /block.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontendEntry": "./frontend/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/SettingsForm.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { 4 | useGlobalConfig, 5 | Box, 6 | Button, 7 | FieldPickerSynced, 8 | FormField, 9 | Heading, 10 | Switch, 11 | TablePickerSynced, 12 | Text, 13 | } from '@airtable/blocks/ui'; 14 | 15 | import {useSettings, ConfigKeys, allowedUrlFieldTypes} from './settings'; 16 | 17 | function SettingsForm({setIsSettingsOpen}) { 18 | const globalConfig = useGlobalConfig(); 19 | const { 20 | isValid, 21 | message, 22 | settings: {isEnforced, urlTable}, 23 | } = useSettings(); 24 | 25 | const canUpdateSettings = globalConfig.hasPermissionToSet(); 26 | 27 | return ( 28 | 37 | 38 | Settings 39 | 40 | { 44 | globalConfig.setAsync(ConfigKeys.IS_ENFORCED, value); 45 | }} 46 | disabled={!canUpdateSettings} 47 | label="Use a specific field for previews" 48 | /> 49 | 50 | {isEnforced 51 | ? 'The app will show previews for the selected record in grid view if the table has a supported URL in the specified field.' 52 | : 'The app will show previews if the selected cell in grid view has a supported URL.'} 53 | 54 | 55 | {isEnforced && ( 56 | 57 | 58 | 59 | )} 60 | {isEnforced && urlTable && ( 61 | 62 | 67 | 68 | )} 69 | 70 | 71 | 78 | {message} 79 | 80 | 88 | 89 | 90 | ); 91 | } 92 | 93 | SettingsForm.propTypes = { 94 | setIsSettingsOpen: PropTypes.func.isRequired, 95 | }; 96 | 97 | export default SettingsForm; 98 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import React, {Fragment, useState, useCallback, useEffect} from 'react'; 2 | import {cursor} from '@airtable/blocks'; 3 | import {ViewType} from '@airtable/blocks/models'; 4 | import { 5 | initializeBlock, 6 | registerRecordActionDataCallback, 7 | useBase, 8 | useRecordById, 9 | useLoadable, 10 | useSettingsButton, 11 | useWatchable, 12 | Box, 13 | Dialog, 14 | Heading, 15 | Link, 16 | Text, 17 | TextButton, 18 | } from '@airtable/blocks/ui'; 19 | 20 | import {useSettings} from './settings'; 21 | import SettingsForm from './SettingsForm'; 22 | 23 | // How this app chooses a preview to show: 24 | // 25 | // Without a specified Table & Field: 26 | // 27 | // - When the user selects a cell in grid view and the field's content is 28 | // a supported preview URL, the app uses this URL to construct an embed 29 | // URL and inserts this URL into an iframe. 30 | // 31 | // To Specify a Table & Field: 32 | // 33 | // - The user may use "Settings" to toggle a specified table and specified 34 | // field constraint. If the constraint switch is set to "Yes",he user must 35 | // set a specified table and specified field for URL previews. 36 | // 37 | // With a specified table & specified field: 38 | // 39 | // - When the user selects a cell in grid view and the active table matches 40 | // the specified table or when the user opens a record from a button field 41 | // in the specified table: 42 | // The app looks in the selected record for the 43 | // specified field containing a supported URL (e.g. https://www.youtube.com/watch?v=KYz2wyBy3kc), 44 | // and uses this URL to construct an embed URL and inserts this URL into 45 | // an iframe. 46 | // 47 | function UrlPreviewApp() { 48 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 49 | useSettingsButton(() => setIsSettingsOpen(!isSettingsOpen)); 50 | 51 | const { 52 | isValid, 53 | settings: {isEnforced, urlTable}, 54 | } = useSettings(); 55 | 56 | // Caches the currently selected record and field in state. If the user 57 | // selects a record and a preview appears, and then the user de-selects the 58 | // record (but does not select another), the preview will remain. This is 59 | // useful when, for example, the user resizes the apps pane. 60 | const [selectedRecordId, setSelectedRecordId] = useState(null); 61 | const [selectedFieldId, setSelectedFieldId] = useState(null); 62 | 63 | const [recordActionErrorMessage, setRecordActionErrorMessage] = useState(''); 64 | 65 | // cursor.selectedRecordIds and selectedFieldIds aren't loaded by default, 66 | // so we need to load them explicitly with the useLoadable hook. The rest of 67 | // the code in the component will not run until they are loaded. 68 | useLoadable(cursor); 69 | 70 | // Update the selectedRecordId and selectedFieldId state when the selected 71 | // record or field change. 72 | useWatchable(cursor, ['selectedRecordIds', 'selectedFieldIds'], () => { 73 | // If the update was triggered by a record being de-selected, 74 | // the current selectedRecordId will be retained. This is 75 | // what enables the caching described above. 76 | if (cursor.selectedRecordIds.length > 0) { 77 | // There might be multiple selected records. We'll use the first 78 | // one. 79 | setSelectedRecordId(cursor.selectedRecordIds[0]); 80 | } 81 | if (cursor.selectedFieldIds.length > 0) { 82 | // There might be multiple selected fields. We'll use the first 83 | // one. 84 | setSelectedFieldId(cursor.selectedFieldIds[0]); 85 | } 86 | }); 87 | 88 | // Close the record action error dialog whenever settings are opened or the selected record 89 | // is updated. (This means you don't have to close the modal to see the settings, or when 90 | // you've opened a different record.) 91 | useEffect(() => { 92 | setRecordActionErrorMessage(''); 93 | }, [isSettingsOpen, selectedRecordId]); 94 | 95 | // Register a callback to be called whenever a record action occurs (via button field) 96 | // useCallback is used to memoize the callback, to avoid having to register/unregister 97 | // it unnecessarily. 98 | const onRecordAction = useCallback( 99 | data => { 100 | // Ignore the event if settings are already open. 101 | // This means we can assume settings are valid (since we force settings to be open if 102 | // they are invalid). 103 | if (!isSettingsOpen) { 104 | if (isEnforced) { 105 | if (data.tableId === urlTable.id) { 106 | setSelectedRecordId(data.recordId); 107 | } else { 108 | // Record is from a mismatching table. 109 | setRecordActionErrorMessage( 110 | `This app is set up to preview URLs using records from the "${urlTable.name}" table, but was opened from a different table.`, 111 | ); 112 | } 113 | } else { 114 | // Preview is not supported in this case, as we wouldn't know what field to preview. 115 | // Show a dialog to the user instead. 116 | setRecordActionErrorMessage( 117 | 'You must enable "Use a specific field for previews" to preview URLs with a button field.', 118 | ); 119 | } 120 | } 121 | }, 122 | [isSettingsOpen, isEnforced, urlTable], 123 | ); 124 | useEffect(() => { 125 | // Return the unsubscribe function to ensure we clean up the handler. 126 | return registerRecordActionDataCallback(onRecordAction); 127 | }, [onRecordAction]); 128 | 129 | // This watch deletes the cached selectedRecordId and selectedFieldId when 130 | // the user moves to a new table or view. This prevents the following 131 | // scenario: User selects a record that contains a preview url. The preview appears. 132 | // User switches to a different table. The preview disappears. The user 133 | // switches back to the original table. Weirdly, the previously viewed preview 134 | // reappears, even though no record is selected. 135 | useWatchable(cursor, ['activeTableId', 'activeViewId'], () => { 136 | setSelectedRecordId(null); 137 | setSelectedFieldId(null); 138 | }); 139 | 140 | const base = useBase(); 141 | const activeTable = base.getTableByIdIfExists(cursor.activeTableId); 142 | 143 | useEffect(() => { 144 | // Display the settings form if the settings aren't valid. 145 | if (!isValid && !isSettingsOpen) { 146 | setIsSettingsOpen(true); 147 | } 148 | }, [isValid, isSettingsOpen]); 149 | 150 | // activeTable is briefly null when switching to a newly created table. 151 | if (!activeTable) { 152 | return null; 153 | } 154 | 155 | return ( 156 | 157 | {isSettingsOpen ? ( 158 | 159 | ) : ( 160 | 166 | )} 167 | {recordActionErrorMessage && ( 168 | setRecordActionErrorMessage('')} maxWidth={400}> 169 | 170 | Can't preview URL 171 | 172 | {recordActionErrorMessage} 173 | 174 | 175 | )} 176 | 177 | ); 178 | } 179 | 180 | // Shows a preview, or a dialog that displays information about what 181 | // kind of services (URLs) are supported by this app. 182 | function RecordPreviewWithDialog({ 183 | activeTable, 184 | selectedRecordId, 185 | selectedFieldId, 186 | setIsSettingsOpen, 187 | }) { 188 | const [isDialogOpen, setIsDialogOpen] = useState(false); 189 | 190 | // Close the dialog when the selected record is changed. 191 | // The new record might have a preview, so we don't want to hide it behind this dialog. 192 | useEffect(() => { 193 | setIsDialogOpen(false); 194 | }, [selectedRecordId]); 195 | 196 | return ( 197 | 198 | 209 | 216 | 217 | {isDialogOpen && ( 218 | setIsDialogOpen(false)} maxWidth={400}> 219 | 220 | Supported services 221 | Previews are supported for these services: 222 | 223 | 227 | Airtable share links 228 | 229 | , Figma, SoundCloud, Spotify, Vimeo, YouTube, Loom share links, Google Drive 230 | share links, Google Docs, Google Sheets, Google Slides 231 | 232 | 237 | Request a new service 238 | 239 | 240 | )} 241 | 242 | ); 243 | } 244 | 245 | // Shows a preview, or a message about what the user should do to see a preview. 246 | function RecordPreview({ 247 | activeTable, 248 | selectedRecordId, 249 | selectedFieldId, 250 | setIsDialogOpen, 251 | setIsSettingsOpen, 252 | }) { 253 | const { 254 | settings: {isEnforced, urlField, urlTable}, 255 | } = useSettings(); 256 | 257 | const table = (isEnforced && urlTable) || activeTable; 258 | 259 | // We use getFieldByIdIfExists because the field might be deleted. 260 | const selectedField = selectedFieldId ? table.getFieldByIdIfExists(selectedFieldId) : null; 261 | // When using a specific field for previews is enabled and that field exists, 262 | // use the selectedField 263 | const previewField = (isEnforced && urlField) || selectedField; 264 | // Triggers a re-render if the record changes. Preview URL cell value 265 | // might have changed, or record might have been deleted. 266 | const selectedRecord = useRecordById(table, selectedRecordId ? selectedRecordId : '', { 267 | fields: [previewField], 268 | }); 269 | 270 | // Triggers a re-render if the user switches table or view. 271 | // RecordPreview may now need to render a preview, or render nothing at all. 272 | useWatchable(cursor, ['activeTableId', 'activeViewId']); 273 | 274 | // This button is re-used in two states so it's pulled out in a constant here. 275 | const viewSupportedURLsButton = ( 276 | setIsDialogOpen(true)}> 277 | View supported URLs 278 | 279 | ); 280 | 281 | if ( 282 | // If there is/was a specified table enforced, but the cursor 283 | // is not presently in the specified table, display a message to the user. 284 | // Exception: selected record is from the specified table (has been opened 285 | // via button field or other means while cursor is on a different table.) 286 | isEnforced && 287 | cursor.activeTableId !== table.id && 288 | !(selectedRecord && selectedRecord.parentTable.id === table.id) 289 | ) { 290 | return ( 291 | 292 | Switch to the “{table.name}” table to see previews. 293 | setIsSettingsOpen(true)}> 294 | Settings 295 | 296 | 297 | ); 298 | } else if ( 299 | // activeViewId is briefly null when switching views 300 | selectedRecord === null && 301 | (cursor.activeViewId === null || 302 | table.getViewById(cursor.activeViewId).type !== ViewType.GRID) 303 | ) { 304 | return Switch to a grid view to see previews; 305 | } else if ( 306 | // selectedRecord will be null on app initialization, after 307 | // the user switches table or view, or if it was deleted. 308 | selectedRecord === null || 309 | // The preview field may have been deleted. 310 | previewField === null 311 | ) { 312 | return ( 313 | 314 | Select a cell to see a preview 315 | {viewSupportedURLsButton} 316 | 317 | ); 318 | } else { 319 | // Using getCellValueAsString guarantees we get a string back. If 320 | // we use getCellValue, we might get back numbers, booleans, or 321 | // arrays depending on the field type. 322 | const cellValue = selectedRecord.getCellValueAsString(previewField); 323 | 324 | if (!cellValue) { 325 | return ( 326 | 327 | The “{previewField.name}” field is empty 328 | {viewSupportedURLsButton} 329 | 330 | ); 331 | } else { 332 | const previewUrl = getPreviewUrlForCellValue(cellValue); 333 | 334 | // In this case, the FIELD_NAME field of the currently selected 335 | // record either contains no URL, or contains a that cannot be 336 | // resolved to a supported preview. 337 | if (!previewUrl) { 338 | return ( 339 | 340 | No preview 341 | {viewSupportedURLsButton} 342 | 343 | ); 344 | } else { 345 | return ( 346 |