├── .block └── remote.json ├── .eslintrc.js ├── .gitignore ├── LICENSE.md ├── README.md ├── block.json ├── frontend ├── index.js └── todo-app.js ├── media └── block.gif ├── package-lock.json ├── package.json └── test ├── .eslintrc.js ├── fixtures └── simple_record_list.js ├── index.jsdom.test.jsx ├── index.jsx ├── index.webdriver.test.js └── jest.setup.js /.block/remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockId": "blkhNEBwxw81IXwJg", 3 | "baseId": "appM8f5iUIHl3mg3h" 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 | .airtableAPIKey 3 | build 4 | cypress/screenshots/ 5 | cypress/videos/ 6 | -------------------------------------------------------------------------------- /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 | # To-do list app 2 | 3 | This example app shows a to-do list based on the records in a table. 4 | 5 | The code shows: 6 | 7 | - How to query and display data from your base. 8 | 9 | - How to use core Airtable functions like "expand record". 10 | 11 | - How to use the built-in component library to let the user choose a table. 12 | 13 | - How to store settings in `globalConfig`. 14 | 15 | - How to create, update, and delete records in your base. 16 | 17 | - How to make your app adapt to the current user's permissions. 18 | 19 | ## How to run this app 20 | 21 | 1. Create a new base (or you can use an existing base). 22 | 23 | 2. Create a new app in your new base (see 24 | [Create a new app](https://airtable.com/developers/blocks/guides/hello-world-tutorial#create-a-new-app)), 25 | selecting "To-do list" as your template. 26 | 27 | 3. From the root of your new app, run `block run`. 28 | 29 | ## See the app running 30 | 31 | ![App updating to-do list as the user changes data](media/block.gif) 32 | -------------------------------------------------------------------------------- /block.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontendEntry": "./frontend/index.js", 3 | "frontendTestingEntry": "./test/index.jsx" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {initializeBlock} from '@airtable/blocks/ui'; 3 | 4 | import TodoApp from './todo-app'; 5 | 6 | initializeBlock(() => ); 7 | -------------------------------------------------------------------------------- /frontend/todo-app.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import { 3 | useBase, 4 | useRecords, 5 | useGlobalConfig, 6 | expandRecord, 7 | TablePickerSynced, 8 | ViewPickerSynced, 9 | FieldPickerSynced, 10 | FormField, 11 | Input, 12 | Button, 13 | Box, 14 | Icon, 15 | } from '@airtable/blocks/ui'; 16 | import {FieldType} from '@airtable/blocks/models'; 17 | 18 | export default function TodoApp() { 19 | const base = useBase(); 20 | 21 | // Read the user's choice for which table and view to use from globalConfig. 22 | const globalConfig = useGlobalConfig(); 23 | const tableId = globalConfig.get('selectedTableId'); 24 | const viewId = globalConfig.get('selectedViewId'); 25 | const doneFieldId = globalConfig.get('selectedDoneFieldId'); 26 | 27 | const table = base.getTableByIdIfExists(tableId); 28 | const view = table ? table.getViewByIdIfExists(viewId) : null; 29 | const doneField = table ? table.getFieldByIdIfExists(doneFieldId) : null; 30 | 31 | // Don't need to fetch records if doneField doesn't exist (the field or it's parent table may 32 | // have been deleted, or may not have been selected yet.) 33 | const records = useRecords(doneField ? view : null, { 34 | fields: doneField ? [table.primaryField, doneField] : [], 35 | }); 36 | 37 | const tasks = records 38 | ? records.map(record => { 39 | return ; 40 | }) 41 | : null; 42 | 43 | return ( 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 61 | {tasks} 62 | {table && doneField && } 63 |
64 | ); 65 | } 66 | 67 | function Task({record, table, doneField}) { 68 | return ( 69 | 78 | 79 | { 82 | expandRecord(record); 83 | }} 84 | > 85 | {record.name || 'Unnamed record'} 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | function TaskDoneCheckbox({table, record, doneField}) { 93 | function onChange(event) { 94 | table.updateRecordAsync(record, { 95 | [doneField.id]: event.currentTarget.checked, 96 | }); 97 | } 98 | 99 | const permissionCheck = table.checkPermissionsForUpdateRecord(record, { 100 | [doneField.id]: undefined, 101 | }); 102 | 103 | return ( 104 | 111 | ); 112 | } 113 | 114 | function TaskDeleteButton({table, record}) { 115 | function onClick() { 116 | table.deleteRecordAsync(record); 117 | } 118 | 119 | return ( 120 | 128 | ); 129 | } 130 | 131 | function AddTaskForm({table}) { 132 | const [taskName, setTaskName] = useState(''); 133 | 134 | function onInputChange(event) { 135 | setTaskName(event.currentTarget.value); 136 | } 137 | 138 | function onSubmit(event) { 139 | event.preventDefault(); 140 | table.createRecordAsync({ 141 | [table.primaryField.id]: taskName, 142 | }); 143 | setTaskName(''); 144 | } 145 | 146 | // check whether or not the user is allowed to create records with values in the primary field. 147 | // if not, disable the form. 148 | const isFormEnabled = table.hasPermissionToCreateRecord({ 149 | [table.primaryField.id]: undefined, 150 | }); 151 | return ( 152 |
153 | 154 | 161 | 164 | 165 |
166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /media/block.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airtable/apps-todo-list/359800f53c707d14108fdb3fc5010a9a28e19d6e/media/block.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@airtable/todo-app", 3 | "version": "2.0.6", 4 | "description": "A to-do list based on records in a table", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "ci": "cd ../../packages/sdk && yarn build && cd - && yarn test", 9 | "cypress": "cypress run", 10 | "lint": "eslint frontend test cypress", 11 | "start-testing": "../../packages/cli/bin/block run --testing", 12 | "test": "npm run lint && npm run test:jsdom", 13 | "test:cypress": "START_SERVER_AND_TEST_INSECURE=1 start-server-and-test start-testing https://localhost:9000 cypress", 14 | "test:jsdom": "jest --testMatch '**/*.jsdom.test.jsx'", 15 | "test:webdriver": "START_SERVER_AND_TEST_INSECURE=1 start-server-and-test start-testing https://localhost:9000 webdriver", 16 | "webdriver": "jest --env node --testMatch '**/*.webdriver.test.js'" 17 | }, 18 | "dependencies": { 19 | "@airtable/blocks": "^1.5.1", 20 | "react": "^16.9.0", 21 | "react-dom": "^16.9.0" 22 | }, 23 | "devDependencies": { 24 | "@airtable/blocks-testing": "^0.0.4", 25 | "@sheerun/mutationobserver-shim": "^0.3.3", 26 | "@testing-library/cypress": "^7.0.3", 27 | "@testing-library/dom": "^7.29.0", 28 | "@testing-library/jest-dom": "^5.11.6", 29 | "@testing-library/react": "^11.2.2", 30 | "@testing-library/user-event": "^12.5.0", 31 | "core-js": "^3.8.1", 32 | "cypress": "^6.2.0", 33 | "eslint": "^6.3.0", 34 | "eslint-plugin-react": "^7.14.3", 35 | "eslint-plugin-react-hooks": "^2.0.1", 36 | "geckodriver": "^2.0.2", 37 | "jest": "^24.9.0", 38 | "selenium-webdriver": "^4.0.0-alpha.8", 39 | "start-server-and-test": "^1.11.6" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | globals: { 6 | afterAll: 'readonly', 7 | afterEach: 'readonly', 8 | beforeAll: 'readonly', 9 | beforeEach: 'readonly', 10 | describe: 'readonly', 11 | expect: 'readonly', 12 | it: 'readonly', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /test/fixtures/simple_record_list.js: -------------------------------------------------------------------------------- 1 | import {FieldType, ViewType} from '@airtable/blocks/models'; 2 | 3 | export default { 4 | base: { 5 | id: 'appTestFixtureDat', 6 | name: 'Test Fixture Data Generation', 7 | tables: [ 8 | { 9 | id: 'tblTable1', 10 | name: 'Groceries', 11 | description: '', 12 | fields: [ 13 | { 14 | id: 'fldName', 15 | name: 'Name', 16 | description: '', 17 | type: FieldType.SINGLE_LINE_TEXT, 18 | options: null, 19 | }, 20 | { 21 | id: 'fldPurchased', 22 | name: 'Purchased', 23 | description: '', 24 | type: FieldType.CHECKBOX, 25 | options: null, 26 | }, 27 | ], 28 | views: [ 29 | { 30 | id: 'viwGridView', 31 | name: 'Grid view', 32 | type: ViewType.GRID, 33 | fieldOrder: { 34 | fieldIds: ['fldName', 'fldPurchased'], 35 | visibleFieldCount: 2, 36 | }, 37 | records: [ 38 | { 39 | id: 'reca', 40 | color: null, 41 | }, 42 | { 43 | id: 'recb', 44 | color: null, 45 | }, 46 | { 47 | id: 'recc', 48 | color: null, 49 | }, 50 | ], 51 | }, 52 | { 53 | id: 'viwGridView2', 54 | name: 'Another grid view', 55 | type: ViewType.GRID, 56 | fieldOrder: { 57 | fieldIds: ['fldName'], 58 | visibleFieldCount: 1, 59 | }, 60 | records: [], 61 | }, 62 | ], 63 | records: [ 64 | { 65 | id: 'reca', 66 | commentCount: 1, 67 | createdTime: '2020-11-04T23:20:08.000Z', 68 | cellValuesByFieldId: { 69 | fldName: 'carrots', 70 | fldPurchased: false, 71 | }, 72 | }, 73 | { 74 | id: 'recb', 75 | commentCount: 2, 76 | createdTime: '2020-11-04T23:20:11.000Z', 77 | cellValuesByFieldId: { 78 | fldName: 'baby carrots', 79 | fldPurchased: true, 80 | }, 81 | }, 82 | { 83 | id: 'recc', 84 | commentCount: 3, 85 | createdTime: '2020-11-04T23:20:14.000Z', 86 | cellValuesByFieldId: { 87 | fldName: 'elderly carrots', 88 | fldPurchased: false, 89 | }, 90 | }, 91 | ], 92 | }, 93 | { 94 | id: 'tblTable2', 95 | name: 'Porcelain dolls', 96 | description: '', 97 | fields: [ 98 | { 99 | id: 'fldName2', 100 | name: 'Name of doll', 101 | description: '', 102 | type: FieldType.SINGLE_LINE_TEXT, 103 | options: null, 104 | }, 105 | ], 106 | views: [ 107 | { 108 | id: 'viwGridView2', 109 | name: 'Grid view', 110 | type: ViewType.GRID, 111 | fieldOrder: { 112 | fieldIds: ['fldName2'], 113 | visibleFieldCount: 1, 114 | }, 115 | records: [], 116 | }, 117 | ], 118 | records: [], 119 | }, 120 | ], 121 | collaborators: [ 122 | { 123 | id: 'usrPhilRath', 124 | name: 'Phil Rath', 125 | email: 'phil.rath@airtable.test', 126 | profilePicUrl: 'https://dl.airtable.test/.profilePics/usrPhilRath', 127 | isActive: true, 128 | }, 129 | ], 130 | }, 131 | }; 132 | -------------------------------------------------------------------------------- /test/index.jsdom.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {act} from 'react-dom/test-utils'; 3 | import TestDriver from '@airtable/blocks-testing'; 4 | import { 5 | render, 6 | screen, 7 | waitFor, 8 | getByRole, 9 | getAllByRole, 10 | getByText, 11 | getNodeText, 12 | } from '@testing-library/react'; 13 | import recordListFixture from './fixtures/simple_record_list'; 14 | import TodoApp from '../frontend/todo-app'; 15 | import userEvent from '@testing-library/user-event'; 16 | 17 | async function openAsync(table, view, field) { 18 | act(() => { 19 | const input = screen.getByLabelText('Table'); 20 | const option = screen.getByText(table); 21 | 22 | userEvent.selectOptions(input, [option]); 23 | }); 24 | 25 | act(() => { 26 | const input = screen.getByLabelText('View'); 27 | const option = screen.getByText(view); 28 | 29 | userEvent.selectOptions(input, [option]); 30 | }); 31 | 32 | act(() => { 33 | const input = screen.getByLabelText('Field'); 34 | const option = screen.getByText(field); 35 | 36 | userEvent.selectOptions(input, [option]); 37 | }); 38 | 39 | return waitFor(() => screen.getByRole('button', {name: 'Add'})); 40 | } 41 | 42 | function getItems() { 43 | return screen.queryAllByRole('checkbox').map(checkbox => { 44 | const container = checkbox.parentNode; 45 | const deleteButton = getByRole(container, 'button'); 46 | const link = getByText(container, container.textContent.trim()); 47 | 48 | return {container, checkbox, deleteButton, link}; 49 | }); 50 | } 51 | 52 | function readItems() { 53 | return getItems().map(item => ({ 54 | checked: item.checkbox.checked, 55 | text: item.container.textContent.trim(), 56 | })); 57 | } 58 | 59 | describe('TodoApp', () => { 60 | let mutations; 61 | let addMutation = mutation => mutations.push(mutation); 62 | let testDriver; 63 | 64 | beforeEach(() => { 65 | testDriver = new TestDriver(recordListFixture); 66 | mutations = []; 67 | testDriver.watch('mutation', addMutation); 68 | 69 | render( 70 | 71 | 72 | , 73 | ); 74 | }); 75 | 76 | afterEach(() => { 77 | testDriver.unwatch('mutations', addMutation); 78 | }); 79 | 80 | it('renders a list of records (user with "write" permissions)', async () => { 81 | await openAsync('Groceries', 'Grid view', 'Purchased'); 82 | 83 | const items = readItems(); 84 | 85 | expect(items.length).toBe(3); 86 | 87 | expect(items).toEqual([ 88 | {checked: false, text: 'carrots'}, 89 | {checked: true, text: 'baby carrots'}, 90 | {checked: false, text: 'elderly carrots'}, 91 | ]); 92 | }); 93 | 94 | // This test cannot be fully expressed using the capabilities currently 95 | // available in the SDK. 96 | it('renders a list of records (user without "write" permissions)', async () => { 97 | testDriver.simulatePermissionCheck(mutation => { 98 | return mutation.type === 'setMultipleGlobalConfigPaths'; 99 | }); 100 | 101 | await openAsync('Groceries', 'Grid view', 'Purchased'); 102 | 103 | expect(screen.getByRole('button', {name: 'Add'}).disabled).toBe(true); 104 | 105 | const items = getItems().map(item => ({ 106 | checked: item.checkbox.checked, 107 | text: item.container.textContent.trim(), 108 | checkboxDisabled: item.checkbox.disabled, 109 | deleteButtonDisabled: item.deleteButton.disabled, 110 | })); 111 | 112 | expect(items.length).toBe(3); 113 | 114 | expect(items).toEqual([ 115 | {checked: false, text: 'carrots', checkboxDisabled: true, deleteButtonDisabled: true}, 116 | { 117 | checked: true, 118 | text: 'baby carrots', 119 | checkboxDisabled: true, 120 | deleteButtonDisabled: true, 121 | }, 122 | { 123 | checked: false, 124 | text: 'elderly carrots', 125 | checkboxDisabled: true, 126 | deleteButtonDisabled: true, 127 | }, 128 | ]); 129 | }); 130 | 131 | it('gracefully handles the deletion of fields', async () => { 132 | await openAsync('Groceries', 'Grid view', 'Purchased'); 133 | 134 | await act(() => testDriver.deleteFieldAsync('tblTable1', 'fldPurchased')); 135 | 136 | const items = readItems(); 137 | 138 | expect(items).toEqual([]); 139 | }); 140 | 141 | it('gracefully handles the deletion of tables', async () => { 142 | await openAsync('Groceries', 'Grid view', 'Purchased'); 143 | 144 | act(() => { 145 | testDriver.deleteTable('tblTable1'); 146 | }); 147 | 148 | const items = readItems(); 149 | 150 | expect(items).toEqual([]); 151 | 152 | const options = getAllByRole(screen.getByLabelText('Table'), 'option'); 153 | 154 | expect(options.map(getNodeText)).toEqual(['Pick a table...', 'Porcelain dolls']); 155 | 156 | expect(options[0].selected).toBe(true); 157 | expect(screen.queryByLabelText('View')).toBe(null); 158 | expect(screen.queryByLabelText('Field')).toBe(null); 159 | }); 160 | 161 | it('gracefully handles the deletion of views', async () => { 162 | await openAsync('Groceries', 'Grid view', 'Purchased'); 163 | 164 | await act(() => testDriver.deleteViewAsync('tblTable1', 'viwGridView')); 165 | 166 | const items = readItems(); 167 | 168 | expect(items).toEqual([]); 169 | 170 | const tableOptions = getAllByRole(screen.getByLabelText('Table'), 'option'); 171 | 172 | expect(tableOptions.map(getNodeText)).toEqual([ 173 | 'Pick a table...', 174 | 'Groceries', 175 | 'Porcelain dolls', 176 | ]); 177 | 178 | expect(tableOptions[1].selected).toBe(true); 179 | 180 | const viewOptions = getAllByRole(screen.getByLabelText('View'), 'option'); 181 | expect(viewOptions.map(getNodeText)).toEqual(['Pick a view...', 'Another grid view']); 182 | expect(viewOptions[0].selected).toBe(true); 183 | 184 | const fieldOptions = getAllByRole(screen.getByLabelText('Field'), 'option'); 185 | expect(fieldOptions.map(getNodeText)).toEqual([ 186 | "Pick a 'done' field...", 187 | 'Name', 188 | 'Purchased', 189 | ]); 190 | expect(fieldOptions[2].selected).toBe(true); 191 | }); 192 | 193 | it('allows records to be created without a name', async () => { 194 | await openAsync('Groceries', 'Grid view', 'Purchased'); 195 | 196 | const initialCount = readItems().length; 197 | 198 | userEvent.click(screen.getByRole('button', {name: 'Add'})); 199 | 200 | const items = readItems(); 201 | 202 | expect(items.length).toBe(initialCount + 1); 203 | expect(items.pop()).toEqual({ 204 | checked: false, 205 | text: 'Unnamed record', 206 | }); 207 | 208 | await waitFor(() => expect(mutations.length).not.toBe(0)); 209 | expect(mutations).toEqual( 210 | expect.arrayContaining([ 211 | { 212 | type: 'createMultipleRecords', 213 | tableId: 'tblTable1', 214 | records: [ 215 | { 216 | id: expect.anything(), 217 | cellValuesByFieldId: { 218 | fldName: '', 219 | }, 220 | }, 221 | ], 222 | }, 223 | ]), 224 | ); 225 | }); 226 | 227 | it('allows multiple records to be created with a name', async () => { 228 | await openAsync('Groceries', 'Grid view', 'Purchased'); 229 | 230 | const initialCount = readItems().length; 231 | 232 | userEvent.type(screen.getByRole('textbox'), 'brash teenaged carrots'); 233 | userEvent.click(screen.getByRole('button', {name: 'Add'})); 234 | 235 | let items = readItems(); 236 | 237 | expect(items.length).toBe(initialCount + 1); 238 | expect(items.pop()).toEqual({ 239 | checked: false, 240 | text: 'brash teenaged carrots', 241 | }); 242 | 243 | await waitFor(() => expect(mutations.length).not.toBe(0)); 244 | expect(mutations).toEqual( 245 | expect.arrayContaining([ 246 | { 247 | type: 'createMultipleRecords', 248 | tableId: 'tblTable1', 249 | records: [ 250 | { 251 | id: expect.anything(), 252 | cellValuesByFieldId: { 253 | fldName: 'brash teenaged carrots', 254 | }, 255 | }, 256 | ], 257 | }, 258 | ]), 259 | ); 260 | 261 | mutations.length = 0; 262 | 263 | userEvent.type(screen.getByRole('textbox'), 'parsnips'); 264 | userEvent.click(screen.getByRole('button', {name: 'Add'})); 265 | 266 | items = readItems(); 267 | 268 | expect(items.length).toBe(initialCount + 2); 269 | 270 | expect(items.pop()).toEqual({ 271 | checked: false, 272 | text: 'parsnips', 273 | }); 274 | 275 | await waitFor(() => expect(mutations.length).not.toBe(0)); 276 | expect(mutations).toEqual( 277 | expect.arrayContaining([ 278 | { 279 | type: 'createMultipleRecords', 280 | tableId: 'tblTable1', 281 | records: [ 282 | { 283 | id: expect.anything(), 284 | cellValuesByFieldId: { 285 | fldName: 'parsnips', 286 | }, 287 | }, 288 | ], 289 | }, 290 | ]), 291 | ); 292 | }); 293 | 294 | it('allows records to be destroyed', async () => { 295 | await openAsync('Groceries', 'Grid view', 'Purchased'); 296 | 297 | userEvent.click(getItems()[1].deleteButton); 298 | 299 | const items = readItems(); 300 | 301 | expect(items).toEqual([ 302 | {checked: false, text: 'carrots'}, 303 | {checked: false, text: 'elderly carrots'}, 304 | ]); 305 | 306 | await waitFor(() => expect(mutations.length).not.toBe(0)); 307 | expect(mutations).toEqual( 308 | expect.arrayContaining([ 309 | { 310 | type: 'deleteMultipleRecords', 311 | tableId: 'tblTable1', 312 | recordIds: ['recb'], 313 | }, 314 | ]), 315 | ); 316 | }); 317 | 318 | it('allows records to be marked as "complete"', async () => { 319 | await openAsync('Groceries', 'Grid view', 'Purchased'); 320 | 321 | userEvent.click(getItems()[0].checkbox); 322 | 323 | const items = readItems(); 324 | 325 | expect(items[0]).toEqual({checked: true, text: 'carrots'}); 326 | 327 | await waitFor(() => expect(mutations.length).not.toBe(0)); 328 | expect(mutations).toEqual( 329 | expect.arrayContaining([ 330 | { 331 | type: 'setMultipleRecordsCellValues', 332 | tableId: 'tblTable1', 333 | records: [ 334 | { 335 | id: 'reca', 336 | cellValuesByFieldId: { 337 | fldPurchased: true, 338 | }, 339 | }, 340 | ], 341 | }, 342 | ]), 343 | ); 344 | }); 345 | 346 | it('allows records to be marked as "incomplete"', async () => { 347 | await openAsync('Groceries', 'Grid view', 'Purchased'); 348 | 349 | userEvent.click(getItems()[1].checkbox); 350 | 351 | const items = readItems(); 352 | 353 | expect(items[1]).toEqual({checked: false, text: 'baby carrots'}); 354 | 355 | await waitFor(() => expect(mutations.length).not.toBe(0)); 356 | expect(mutations).toEqual( 357 | expect.arrayContaining([ 358 | { 359 | type: 'setMultipleRecordsCellValues', 360 | tableId: 'tblTable1', 361 | records: [ 362 | { 363 | id: 'recb', 364 | cellValuesByFieldId: { 365 | fldPurchased: false, 366 | }, 367 | }, 368 | ], 369 | }, 370 | ]), 371 | ); 372 | }); 373 | 374 | it('expands records upon click', async () => { 375 | await openAsync('Groceries', 'Grid view', 'Purchased'); 376 | const recordIds = []; 377 | testDriver.watch('expandRecord', ({recordId}) => recordIds.push(recordId)); 378 | 379 | userEvent.click(getItems()[0].link); 380 | await waitFor(() => expect(recordIds.length).not.toBe(0)); 381 | 382 | expect(recordIds).toEqual(['reca']); 383 | }); 384 | }); 385 | -------------------------------------------------------------------------------- /test/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TestDriver from '@airtable/blocks-testing'; 4 | 5 | import recordListFixture from './fixtures/simple_record_list'; 6 | import TodoApp from '../frontend/todo-app'; 7 | 8 | window.testDriver = new TestDriver(recordListFixture); 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.body.appendChild(document.createElement('main')), 15 | ); 16 | -------------------------------------------------------------------------------- /test/index.webdriver.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {readFile: readFileAsync} = require('fs').promises; 4 | const {Builder} = require('selenium-webdriver'); 5 | 6 | /** 7 | * Execute source text in a remote context as though it were the body of an 8 | * async function. 9 | * 10 | * This helper is necessary because the relevant behavior in the WebDriver 11 | * command named "Execute async script" is poorly defined: 12 | * 13 | * [1] https://github.com/w3c/webdriver/pull/1431 14 | */ 15 | async function executeScriptAsync(driver, source) { 16 | const result = await driver.executeAsyncScript(` 17 | var done = arguments[arguments.length - 1]; 18 | (async () => { 19 | ${source}; 20 | })().then( 21 | (value) => done({status: 'success', value}), 22 | (error) => done({status: 'error', value: error.message || error}) 23 | ); 24 | `); 25 | 26 | if (result.status === 'error') { 27 | throw result.value; 28 | } 29 | 30 | return result.value; 31 | } 32 | 33 | async function openAsync(driver, table, view, field) { 34 | { 35 | const option = await executeScriptAsync( 36 | driver, 37 | ` 38 | const input = await TestingLibraryDom.getByLabelText( 39 | document.body, 'Table', 40 | ); 41 | return TestingLibraryDom.getByText(input, ${JSON.stringify(table)}); 42 | `, 43 | ); 44 | 45 | await option.click(); 46 | } 47 | 48 | { 49 | const option = await executeScriptAsync( 50 | driver, 51 | ` 52 | const input = await TestingLibraryDom.getByLabelText( 53 | document.body, 'View', 54 | ); 55 | return TestingLibraryDom.getByText(input, ${JSON.stringify(view)}); 56 | `, 57 | ); 58 | 59 | await option.click(); 60 | } 61 | 62 | { 63 | const option = await executeScriptAsync( 64 | driver, 65 | ` 66 | const input = await TestingLibraryDom.getByLabelText( 67 | document.body, 'Field', 68 | ); 69 | return TestingLibraryDom.getByText(input, ${JSON.stringify(field)}); 70 | `, 71 | ); 72 | 73 | await option.click(); 74 | } 75 | } 76 | 77 | async function getItemsAsync(driver) { 78 | return executeScriptAsync( 79 | driver, 80 | ` 81 | const checkboxes = await TestingLibraryDom.queryAllByRole( 82 | document.body, 'checkbox' 83 | ); 84 | 85 | return checkboxes.map((checkbox) => { 86 | const container = checkbox.parentNode; 87 | const deleteButton = TestingLibraryDom.getByRole(container, 'button'); 88 | const link = TestingLibraryDom.getByText(container, container.textContent.trim()); 89 | 90 | return {container, checkbox, deleteButton, link}; 91 | }); 92 | `, 93 | ); 94 | } 95 | 96 | async function readItemsAsync(driver) { 97 | const items = await getItemsAsync(driver); 98 | return Promise.all( 99 | items.map(async item => ({ 100 | checked: (await item.checkbox.getAttribute('checked')) === 'true', 101 | text: (await item.container.getText()).trim(), 102 | })), 103 | ); 104 | } 105 | 106 | /** 107 | * Create an item and pause until the UI has updated in response to the 108 | * creation, reducing the risk of race conditions in subsequent operations. 109 | */ 110 | async function createItemAsync(driver, text) { 111 | const input = await executeScriptAsync( 112 | driver, 113 | `return TestingLibraryDom.getByRole(document.body, 'textbox');`, 114 | ); 115 | await input.sendKeys(text); 116 | 117 | const addButton = await executeScriptAsync( 118 | driver, 119 | ` 120 | return TestingLibraryDom.getByRole( 121 | document.body, 'button', {name: 'Add'} 122 | ); 123 | `, 124 | ); 125 | 126 | const initialItems = await getItemsAsync(driver); 127 | 128 | await addButton.click(); 129 | 130 | return (async function pollAsync() { 131 | if ((await getItemsAsync(driver)).length === initialItems.length) { 132 | return pollAsync(); 133 | } 134 | 135 | return readItemsAsync(driver); 136 | })(); 137 | } 138 | 139 | /** 140 | * Delete an item and pause until the UI has updated in response to the 141 | * deletion, reducing the risk of race conditions in subsequent operations. 142 | */ 143 | async function deleteItemAsync(driver, index) { 144 | const initialItems = await getItemsAsync(driver); 145 | 146 | initialItems[index].deleteButton.click(); 147 | 148 | return (async function pollAsync() { 149 | if ((await getItemsAsync(driver)).length === initialItems.length) { 150 | return pollAsync(); 151 | } 152 | 153 | return readItemsAsync(driver); 154 | })(); 155 | } 156 | 157 | async function flushMutationsAsync(driver) { 158 | return driver.executeScript(` 159 | return window.mutations.splice(0, window.mutations.length); 160 | `); 161 | } 162 | 163 | describe('TodoApp', () => { 164 | let testingLibrarySource; 165 | let driver; 166 | 167 | beforeAll(async () => { 168 | driver = new Builder() 169 | .withCapabilities({acceptInsecureCerts: true}) 170 | .forBrowser('firefox') 171 | .build(); 172 | 173 | const testingLibraryFilename = require.resolve( 174 | '@testing-library/dom/dist/@testing-library/dom.umd.js', 175 | ); 176 | 177 | // Work around bug in Geckodriver 178 | // 179 | // "Each executed script has a different global scope" 180 | // https://github.com/mozilla/geckodriver/issues/1798 181 | testingLibrarySource = `(function(globalThis) { 182 | ${await readFileAsync(testingLibraryFilename)}; 183 | }(window));`; 184 | }); 185 | 186 | afterAll(() => driver.quit()); 187 | 188 | beforeEach(async () => { 189 | await driver.get('https://localhost:9000'); 190 | await driver.executeScript(testingLibrarySource); 191 | await driver.executeScript(` 192 | window.mutations = []; 193 | window.testDriver.watch( 194 | 'mutation', (mutation) => window.mutations.push(mutation) 195 | ); 196 | `); 197 | }); 198 | 199 | it('renders a list of records (user with "write" permissions)', async () => { 200 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 201 | 202 | const items = await readItemsAsync(driver); 203 | 204 | expect(items.length).toBe(3); 205 | 206 | expect(items).toEqual([ 207 | {checked: false, text: 'carrots'}, 208 | {checked: true, text: 'baby carrots'}, 209 | {checked: false, text: 'elderly carrots'}, 210 | ]); 211 | }); 212 | 213 | it('renders a list of records (user without "write" permissions)', async () => { 214 | driver.executeScript(` 215 | testDriver.simulatePermissionCheck(mutation => { 216 | return mutation.type === 'setMultipleGlobalConfigPaths'; 217 | }); 218 | `); 219 | 220 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 221 | 222 | const addButton = await executeScriptAsync( 223 | driver, 224 | ` 225 | return TestingLibraryDom.getByRole( 226 | document.body, 'button', {name: 'Add'} 227 | ); 228 | `, 229 | ); 230 | expect(await addButton.getAttribute('disabled')).toBe('true'); 231 | 232 | const items = await Promise.all( 233 | (await getItemsAsync(driver)).map(async item => ({ 234 | checked: (await item.checkbox.getAttribute('checked')) === 'true', 235 | text: (await item.container.getText()).trim(), 236 | checkboxDisabled: (await item.checkbox.getAttribute('disabled')) === 'true', 237 | deleteButtonDisabled: (await item.deleteButton.getAttribute('disabled')) === 'true', 238 | })), 239 | ); 240 | 241 | expect(items.length).toBe(3); 242 | 243 | expect(items).toEqual([ 244 | {checked: false, text: 'carrots', checkboxDisabled: true, deleteButtonDisabled: true}, 245 | { 246 | checked: true, 247 | text: 'baby carrots', 248 | checkboxDisabled: true, 249 | deleteButtonDisabled: true, 250 | }, 251 | { 252 | checked: false, 253 | text: 'elderly carrots', 254 | checkboxDisabled: true, 255 | deleteButtonDisabled: true, 256 | }, 257 | ]); 258 | }); 259 | 260 | it('allows records to be created without a name', async () => { 261 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 262 | 263 | const initialCount = (await readItemsAsync(driver)).length; 264 | 265 | const items = await createItemAsync(driver, ''); 266 | 267 | expect(items.length).toBe(initialCount + 1); 268 | expect(items.pop()).toEqual({ 269 | checked: false, 270 | text: 'Unnamed record', 271 | }); 272 | 273 | expect(await flushMutationsAsync(driver)).toEqual( 274 | expect.arrayContaining([ 275 | { 276 | type: 'createMultipleRecords', 277 | tableId: 'tblTable1', 278 | records: [ 279 | { 280 | id: expect.anything(), 281 | cellValuesByFieldId: { 282 | fldName: '', 283 | }, 284 | }, 285 | ], 286 | }, 287 | ]), 288 | ); 289 | }); 290 | 291 | it('allows multiple records to be created with a name', async () => { 292 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 293 | 294 | const initialCount = (await readItemsAsync(driver)).length; 295 | 296 | let items = await createItemAsync(driver, 'brash teenaged carrots'); 297 | 298 | expect(items.length).toBe(initialCount + 1); 299 | expect(items.pop()).toEqual({ 300 | checked: false, 301 | text: 'brash teenaged carrots', 302 | }); 303 | 304 | expect(await flushMutationsAsync(driver)).toEqual( 305 | expect.arrayContaining([ 306 | { 307 | type: 'createMultipleRecords', 308 | tableId: 'tblTable1', 309 | records: [ 310 | { 311 | id: expect.anything(), 312 | cellValuesByFieldId: { 313 | fldName: 'brash teenaged carrots', 314 | }, 315 | }, 316 | ], 317 | }, 318 | ]), 319 | ); 320 | 321 | items = await createItemAsync(driver, 'parsnips'); 322 | 323 | expect(items.length).toBe(initialCount + 2); 324 | 325 | expect(items.pop()).toEqual({ 326 | checked: false, 327 | text: 'parsnips', 328 | }); 329 | 330 | expect(await flushMutationsAsync(driver)).toEqual( 331 | expect.arrayContaining([ 332 | { 333 | type: 'createMultipleRecords', 334 | tableId: 'tblTable1', 335 | records: [ 336 | { 337 | id: expect.anything(), 338 | cellValuesByFieldId: { 339 | fldName: 'parsnips', 340 | }, 341 | }, 342 | ], 343 | }, 344 | ]), 345 | ); 346 | }); 347 | 348 | it('allows records to be destroyed', async () => { 349 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 350 | 351 | const items = await deleteItemAsync(driver, 1); 352 | 353 | expect(items).toEqual([ 354 | {checked: false, text: 'carrots'}, 355 | {checked: false, text: 'elderly carrots'}, 356 | ]); 357 | 358 | expect(await flushMutationsAsync(driver)).toEqual( 359 | expect.arrayContaining([ 360 | { 361 | type: 'deleteMultipleRecords', 362 | tableId: 'tblTable1', 363 | recordIds: ['recb'], 364 | }, 365 | ]), 366 | ); 367 | }); 368 | 369 | it('allows records to be marked as "complete"', async () => { 370 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 371 | 372 | await (await getItemsAsync(driver))[0].checkbox.click(); 373 | 374 | const items = await readItemsAsync(driver); 375 | 376 | expect(items[0]).toEqual({checked: true, text: 'carrots'}); 377 | 378 | expect(await flushMutationsAsync(driver)).toEqual( 379 | expect.arrayContaining([ 380 | { 381 | type: 'setMultipleRecordsCellValues', 382 | tableId: 'tblTable1', 383 | records: [ 384 | { 385 | id: 'reca', 386 | cellValuesByFieldId: { 387 | fldPurchased: true, 388 | }, 389 | }, 390 | ], 391 | }, 392 | ]), 393 | ); 394 | }); 395 | 396 | it('allows records to be marked as "incomplete"', async () => { 397 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 398 | 399 | await (await getItemsAsync(driver))[1].checkbox.click(); 400 | 401 | const items = await readItemsAsync(driver); 402 | 403 | expect(items[1]).toEqual({checked: false, text: 'baby carrots'}); 404 | 405 | expect(await flushMutationsAsync(driver)).toEqual( 406 | expect.arrayContaining([ 407 | { 408 | type: 'setMultipleRecordsCellValues', 409 | tableId: 'tblTable1', 410 | records: [ 411 | { 412 | id: 'recb', 413 | cellValuesByFieldId: { 414 | fldPurchased: false, 415 | }, 416 | }, 417 | ], 418 | }, 419 | ]), 420 | ); 421 | }); 422 | 423 | // This test cannot pass because the SDK's `expandRecords` function relies 424 | // on a global reference to AirtableInterface which cannot be controlled in 425 | // the test environment. 426 | it.skip('expands records upon click', async () => { 427 | await openAsync(driver, 'Groceries', 'Grid view', 'Purchased'); 428 | 429 | await (await getItemsAsync(driver))[0].link.click(); 430 | }); 431 | }); 432 | -------------------------------------------------------------------------------- /test/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import MutationObserver from '@sheerun/mutationobserver-shim'; 3 | 4 | // Shim the MutationObserver constructor in browser-like environments. This is 5 | // a workaround for the older release of JSDOM which is used by the version of 6 | // Jest upon which this project depends. 7 | // 8 | // https://github.com/testing-library/dom-testing-library/releases/tag/v7.0.0 9 | if (typeof window === 'object' && window) { 10 | if ('MutationObserver' in window) { 11 | throw new Error( 12 | 'MutationObserver present in `window`. If this is expected, remove the `@sheerun/mutationobserver-shim` package.', 13 | ); 14 | } 15 | 16 | window.MutationObserver = MutationObserver; 17 | } 18 | --------------------------------------------------------------------------------