├── .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 | 
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 |
126 |
127 |
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 |
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 |
--------------------------------------------------------------------------------