diff --git a/CHANGELOG.md b/CHANGELOG.md index 7189c53..ce040d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-08-02 + +### Added + +- `examples/field-types`: a workbench extension covering every `FieldType`, with a smoke suite that validates each type's documented cell read format against the SDK and each editor's mutations against the documented write format. +- `fixtures/field-types.ts`: a fixture with every creatable Airtable field type and full option permutations, generated from a real base. +- The fixture generator warns when exporting `multipleLookupValues` fields: the REST API flattens lookups, so the documented `Array<{linkedRecordId, value}>` shape needs hand-editing. + +### Fixed + +- Reading a `multipleLookupValues` cell no longer throws: fixtures now declare lookups in the SDK's documented read format, and the test driver initializes the SDK accordingly (`isUsingNewLookupCellValueFormat`). + ## [0.2.0] - 2026-08-01 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index e1d47b5..f18d6f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ npm workspaces monorepo: - `packages/testing` — `@usdr/airtable-interface-testing`: the `TestDriver` library. - `packages/fixture-generator` — `@usdr/airtable-interface-testing-fixtures`: CLI that exports a real base to fixture files via the Airtable REST API. - `examples/todo-list` — example extension + consumer-style Jest suite; doubles as integration test and documentation. +- `examples/field-types` — workbench extension + data-driven smoke suite covering every `FieldType`'s documented cell read/write formats, built on `fixtures/field-types.ts` (generated from a real base) extended with the eight read-only types the Meta API can't create. - `_blocks/` (gitignored) — reference checkouts of the `v1` and `interface-alpha` branches of Airtable/blocks. Read-only reference; never a build input. ## Commands @@ -52,6 +53,8 @@ npm workspaces monorepo: ## Known constraints - Pinned/validated SDK build: `@airtable/blocks@interface-alpha` = `0.0.0-experimental-8575f0e0d-20260428` — the tag Airtable's own extension templates install, so it's what most consumers have. The suite also passes against `interface-alpha-next`. A new experimental build can move internals; the library fails loudly at import if the dist files vanish. Update the README's validated-version note when bumping. +- **The two builds differ in their record-loading model:** the pinned `interface-alpha` build has NO `table.selectRecords()`/query-result API — its `useRecords(table)` reads the record store synchronously from `recordsById`/`recordOrder`. `interface-alpha-next` introduced query results + `loadDynamicQueryAsync` (which is why the mock implements it; it's simply never called on the older build). Tests that need Record models portably should collect them through `useRecords` in a rendered component, not `selectRecords`. +- **Lookup cell values:** the fixture converter sets `isUsingNewLookupCellValueFormat: true` in SdkInitData, so fixtures declare `multipleLookupValues` cells in the SDK's documented public read format `Array<{linkedRecordId, value}>`. Without that flag the SDK expects hyperbase's internal shape and `getCellValue` throws. The generator CLI cannot produce the documented shape (the REST API flattens lookups) — it passes values through with a warning. - **The two builds differ in their `exports` map:** `interface-alpha-next` exports `./package.json`, `interface-alpha` does not. `resolveSdkRoot()` in `src/sdk_internals.ts` therefore tries `./package.json` first and falls back to resolving `@airtable/blocks/interface/ui` and walking up to the owning directory. Don't "simplify" it back to a single `require.resolve('@airtable/blocks/package.json')` — that breaks `interface-alpha` entirely. - Changing the SDK dist-tag needs the lockfile entry dropped to take effect: npm won't re-resolve (or downgrade) a dist-tag spec on its own. Edit both workspaces' `package.json`, delete `packages['node_modules/@airtable/blocks']` from `package-lock.json`, then `npm install`. - The SDK's published ESM uses extensionless relative imports → **only runs under a transforming runner** (Jest+babel validated; Vitest plausible, unvalidated; plain Node impossible). Don't chase plain-Node support. @@ -67,6 +70,8 @@ Security rules adapted from [TikiTribe/claude-secure-coding-rules](https://githu ## Work log +- **2026-08-02** — Version 0.3.0: all five `package.json` files bumped in lockstep, CHANGELOG.md entry added (field-types example + fixture, lookup read-format fix, generator lookup warning), release-URL examples in the four docs moved to v0.3.0. Release goes out via tag-on-merge when this lands on `main`. +- **2026-08-02** — Added `examples/field-types`: a list-and-edit workbench over both fixture tables with per-FieldType editors, plus a smoke suite that iterates the SDK's `FieldType` enum and validates every cell value against the documented read format and every editor mutation against the documented write format (79 tests). Enabling it surfaced two real fixes: the converter now sets `isUsingNewLookupCellValueFormat: true` (fixtures use the documented lookup shape; without the flag `getCellValue` throws on lookups), and the generator warns that REST lookups export flat. `fixtures/field-types.ts` corrected accordingly (lookup shape, collaborator coverage). Learned: the pinned `interface-alpha` build has no `selectRecords` API — see Known constraints. - **2026-08-01** — All four `package.json` files bumped to 0.2.0 and kept in lockstep from now on: `scripts/check-versions.mjs` (root script `check:versions`) fails when any two disagree, wired into ci.yml before lint and release.yml before the tag guard. A `testing` 0.2.0 / `fixture-generator` 0.1.0 split is what broke the first release. Release-URL examples in all four docs moved to v0.2.0; the earlier "root stays at 0.0.0" guidance is retired. - **2026-07-23** — Switched the pinned SDK from `interface-alpha-next` to `interface-alpha` (what Airtable's templates install). Required a real fix: `interface-alpha` doesn't export `./package.json`, so `sdk_internals.ts` gained `resolveSdkRoot()` with an entry-point fallback. Full suite verified green against both dist-tags. diff --git a/README.md b/README.md index 9b0343b..3394a61 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ You get a `TestDriver` with the same shape as the v1 library — fixture data in These packages aren't on npm yet — we attach tarballs to [GitHub Releases](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases) instead. Open the latest release, copy the link to the `.tgz` you want under **Assets**, and hand it to npm: ```bash -npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.2.0/usdr-airtable-interface-testing-0.2.0.tgz +npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.3.0/usdr-airtable-interface-testing-0.3.0.tgz ``` Release assets always follow the same shape, so you can bump the version in that URL directly: @@ -42,6 +42,8 @@ npm writes the URL into your lockfile, so installs stay reproducible. [Getting s **[examples/todo-list](examples/todo-list) ---** a small interface extension with a complete test suite. If you want to see the library in use before reading docs, read [its test file](examples/todo-list/test/app.test.tsx). +**[examples/field-types](examples/field-types) ---** a workbench extension covering every `FieldType`, with a data-driven smoke suite asserting each type's documented cell read and write formats. The reference for "what does this field type's cell value look like?" + ## Quick start for working in this project If you want to work on the actual testing repo itself: diff --git a/docs/getting-started.md b/docs/getting-started.md index 799fa0d..2a8dbfa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,7 +13,7 @@ For a live example, check out the [Example extension](../examples/todo-list). This package isn't on npm yet — we publish tarballs on [GitHub Releases](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases) instead. After setting up your Airtable project, install the latest release: ```bash -npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.2.0/usdr-airtable-interface-testing-0.2.0.tgz +npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.3.0/usdr-airtable-interface-testing-0.3.0.tgz ``` **Finding the URL ---** open the [releases page](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases), pick a release, and look under **Assets**. Copy the link address of `usdr-airtable-interface-testing-.tgz` — that's the URL you pass to `npm install`. The URLs always follow the same pattern, so you can also just edit the version in the command above: @@ -74,14 +74,14 @@ Every test follows the same four-step pattern: create a test driver from fixture ### 1. Create a test driver with fixture data -Each test starts by instantiating a `TestDriver` with fixture data. We suggest saving this as a single file for resuse. +Each test starts by instantiating a `TestDriver` with fixture data. We suggest saving this as a single file for reuse. ```tsx import TestDriver from "@usdr/airtable-interface-testing"; import fixtureData from "./fixtures/my-base"; const testDriver = new TestDriver(fixtureData); -export testDriver +export default testDriver; ``` ### 2. Render the extension diff --git a/examples/field-types/README.md b/examples/field-types/README.md new file mode 100644 index 0000000..8f683b9 --- /dev/null +++ b/examples/field-types/README.md @@ -0,0 +1,17 @@ +# Field types example + +A workbench extension covering **every [FieldType](https://airtable.com/developers/interface-extensions/api/FieldType)**, used as a smoke test for all cell value formats. If you want to know what a given field type's cell value looks like — or how to write one back — this example is the reference. + +The app lists every record of every table in the base and renders a type-appropriate editor for each field: text inputs for the string family (committing on blur), selects and checkbox groups for choices, cross-table checkboxes for record links, an append-by-URL control for attachments, and plain text for the read-only computed types (formula, rollup, count, lookups, barcode, button, and the created/modified metadata fields). + +Two suites keep it honest: + +**[format_coverage.test.tsx](test/format_coverage.test.tsx) ---** iterates the SDK's own `FieldType` enum and asserts the fixture contains every type, and that every cell value read through the real SDK matches that type's documented **cell read format**. If Airtable adds a field type, this suite fails until the fixture covers it — that's the point. + +**[app.test.tsx](test/app.test.tsx) ---** renders the UI and edits a representative field of each editable kind, asserting the emitted mutations carry the documented **cell write format** (`{id}` for selects, `Array<{id, name}>` for record links, appended `{url}` for attachments, and so on). + +The fixture comes from [fixtures/field-types.ts](../../fixtures/field-types.ts) — generated from a real base holding every field type the Meta API can create — extended in [test/fixtures.ts](test/fixtures.ts) with the eight read-only types the API cannot create (autoNumber, button, createdTime, lastModifiedTime, createdBy, lastModifiedBy, externalSyncSource, aiText). + +```bash +npm test +``` diff --git a/examples/field-types/jest.config.js b/examples/field-types/jest.config.js new file mode 100644 index 0000000..aa50b3b --- /dev/null +++ b/examples/field-types/jest.config.js @@ -0,0 +1,3 @@ +export default { + preset: '@usdr/airtable-interface-testing', +}; diff --git a/examples/field-types/package.json b/examples/field-types/package.json new file mode 100644 index 0000000..126ad03 --- /dev/null +++ b/examples/field-types/package.json @@ -0,0 +1,23 @@ +{ + "name": "@usdr/example-field-types", + "private": true, + "version": "0.3.0", + "description": "Example Airtable interface extension covering every FieldType, used as a smoke test for all cell value formats", + "type": "module", + "scripts": { + "test": "jest", + "types": "tsc" + }, + "dependencies": { + "@airtable/blocks": "interface-alpha", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@usdr/airtable-interface-testing": "*", + "typescript": "^5.6.0" + } +} diff --git a/examples/field-types/src/app.tsx b/examples/field-types/src/app.tsx new file mode 100644 index 0000000..2565e98 --- /dev/null +++ b/examples/field-types/src/app.tsx @@ -0,0 +1,358 @@ +import React, {useState} from 'react'; +import {useBase, useRecords} from '@airtable/blocks/interface/ui'; +import { + FieldType, + type Base, + type Field, + type Record as AirtableRecord, + type Table, +} from '@airtable/blocks/interface/models'; + +/** + * Every field type the extension can WRITE, with the documented cell write + * format produced by its editor: + * + * - text-ish types write a string; date/dateTime write an ISO string + * - number-ish types write a number + * - checkbox writes a boolean + * - singleSelect writes {id} (or null to clear) + * - multipleSelects writes Array<{id}> + * - multipleRecordLinks writes Array<{id, name}> + * - multipleAttachments appends {url} to the existing array + * - singleCollaborator writes {id}; multipleCollaborators writes Array<{id}> + * + * Everything else (formula, rollup, count, lookups, barcode, button, + * auto/created/modified metadata, aiText, externalSyncSource) is read-only + * per the FieldType docs and rendered as text. + */ + +const TEXT_TYPES: ReadonlyArray = [ + FieldType.SINGLE_LINE_TEXT, + FieldType.MULTILINE_TEXT, + FieldType.RICH_TEXT, + FieldType.EMAIL, + FieldType.URL, + FieldType.PHONE_NUMBER, + FieldType.DATE, + FieldType.DATE_TIME, +]; + +const NUMBER_TYPES: ReadonlyArray = [ + FieldType.NUMBER, + FieldType.PERCENT, + FieldType.CURRENCY, + FieldType.DURATION, + FieldType.RATING, +]; + +/** Text input that commits its value on blur (not per keystroke). */ +function CommitInput({ + ariaLabel, + initial, + onCommit, +}: { + ariaLabel: string; + initial: string; + onCommit: (value: string) => void; +}) { + const [draft, setDraft] = useState(initial); + return ( + setDraft(event.target.value)} + onBlur={() => { + if (draft !== initial) { + onCommit(draft); + } + }} + /> + ); +} + +function controlId(record: AirtableRecord, field: Field): string { + return `${record.id}:${field.id}`; +} + +function LinkedRecordsEditor({ + table, + record, + field, +}: { + table: Table; + record: AirtableRecord; + field: Field; +}) { + const base = useBase(); + const options = (field.config as {options: any}).options; + const linkedTable = base.getTableById(options.linkedTableId); + const candidates = useRecords(linkedTable); + const current = (record.getCellValue(field.id) ?? []) as Array<{id: string; name: string}>; + const currentIds = new Set(current.map((link) => link.id)); + + return ( + + {candidates.map((candidate: AirtableRecord) => ( + + ))} + + ); +} + +/** Render a read-only cell value as text, following the documented read formats. */ +export function formatReadOnlyValue(fieldType: string, value: unknown): string { + if (value === null || value === undefined) { + return '(empty)'; + } + switch (fieldType) { + case FieldType.MULTIPLE_LOOKUP_VALUES: + return (value as Array<{value: unknown}>) + .map((entry) => String(entry.value)) + .join(', '); + case FieldType.BARCODE: + return (value as {text: string}).text; + case FieldType.BUTTON: + return (value as {label: string}).label; + case FieldType.CREATED_BY: + case FieldType.LAST_MODIFIED_BY: + return (value as {name?: string; email: string}).name ?? (value as {email: string}).email; + case FieldType.AI_TEXT: { + const aiValue = value as {state: string; value: string}; + return `${aiValue.value} (${aiValue.state})`; + } + case FieldType.EXTERNAL_SYNC_SOURCE: + return (value as {name: string}).name; + default: + return String(value); + } +} + +function FieldEditor({ + table, + record, + field, +}: { + table: Table; + record: AirtableRecord; + field: Field; +}) { + const id = controlId(record, field); + const value = record.getCellValue(field.id); + const write = (newValue: unknown) => table.updateRecordAsync(record, {[field.id]: newValue}); + + if (TEXT_TYPES.includes(field.type)) { + return ( + write(draft === '' ? null : draft)} + /> + ); + } + + if (NUMBER_TYPES.includes(field.type)) { + return ( + { + if (draft === '') { + write(null); + } else if (!Number.isNaN(Number(draft))) { + write(Number(draft)); + } + }} + /> + ); + } + + switch (field.type) { + case FieldType.CHECKBOX: + return ( + write(value === true ? null : true)} + /> + ); + case FieldType.SINGLE_SELECT: { + const choices = (field.config as {options: any}).options.choices as Array<{ + id: string; + name: string; + }>; + const current = value as {id: string} | null; + return ( + + ); + } + case FieldType.MULTIPLE_SELECTS: { + const choices = (field.config as {options: any}).options.choices as Array<{ + id: string; + name: string; + }>; + const current = (value ?? []) as Array<{id: string}>; + const currentIds = new Set(current.map((choice) => choice.id)); + return ( + + {choices.map((choice) => ( + + ))} + + ); + } + case FieldType.MULTIPLE_RECORD_LINKS: + return ; + case FieldType.MULTIPLE_ATTACHMENTS: { + const attachments = (value ?? []) as Array<{filename?: string; url: string}>; + return ( + + {attachments.map((attachment, index) => ( + {attachment.filename ?? attachment.url} + ))} + { + if (url !== '') { + write([...attachments, {url}]); + } + }} + /> + + ); + } + case FieldType.SINGLE_COLLABORATOR: { + const collaborator = value as {id: string; name?: string; email?: string} | null; + return ( + + {collaborator ? `${collaborator.name ?? collaborator.email} ` : ''} + write(userId === '' ? null : {id: userId})} + /> + + ); + } + case FieldType.MULTIPLE_COLLABORATORS: { + const collaborators = (value ?? []) as Array<{id: string; name?: string}>; + return ( + + {collaborators.map((collaborator) => collaborator.name).join(', ')}{' '} + collaborator.id).join(',')} + onCommit={(ids) => + write( + ids === '' + ? [] + : ids.split(',').map((userId) => ({id: userId.trim()})), + ) + } + /> + + ); + } + default: + // Read-only types: formula, rollup, count, lookups, barcode, + // button, autoNumber, createdTime/By, lastModifiedTime/By, + // aiText, externalSyncSource. + return {formatReadOnlyValue(field.type, value)}; + } +} + +function RecordCard({table, record}: {table: Table; record: AirtableRecord}) { + return ( +
  • +

    {record.name}

    +
    + {table.fields.map((field: Field) => ( +
    +
    + {field.name} {field.type} +
    +
    + +
    +
    + ))} +
    +
  • + ); +} + +function TableSection({table}: {table: Table}) { + const records = useRecords(table); + return ( +
    +

    + {table.name} ({records.length} records) +

    +
      + {records.map((record: AirtableRecord) => ( + + ))} +
    +
    + ); +} + +/** + * Lists every record of every table in the base and renders a + * type-appropriate editor (or read-only view) for each field — a workbench + * covering every {@link FieldType}. + */ +export function FieldTypesApp() { + const base = useBase(); + return ( +
    +

    {base.name}: field type workbench

    + {base.tables.map((table: Base['tables'][number]) => ( + + ))} +
    + ); +} diff --git a/examples/field-types/src/index.tsx b/examples/field-types/src/index.tsx new file mode 100644 index 0000000..db4c717 --- /dev/null +++ b/examples/field-types/src/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import {initializeBlock} from '@airtable/blocks/interface/ui'; +import {FieldTypesApp} from './app'; + +// Tests never import this module — they render inside +// TestDriver.Container instead. +initializeBlock({interface: () => }); diff --git a/examples/field-types/test/app.test.tsx b/examples/field-types/test/app.test.tsx new file mode 100644 index 0000000..fa614b6 --- /dev/null +++ b/examples/field-types/test/app.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TestDriver, {MutationTypes, type Mutation} from '@usdr/airtable-interface-testing'; +import {FieldTypesApp} from '../src/app'; +import {makeFixtureData, ROW_ALL_ID, ROW_EDGE_ID, TASKS_TABLE_ID} from './fixtures'; + +describe('FieldTypesApp', () => { + let testDriver: TestDriver; + let mutations: Array; + + beforeEach(() => { + testDriver = new TestDriver(makeFixtureData()); + mutations = []; + testDriver.watch('mutation', (mutation) => mutations.push(mutation)); + }); + + function renderApp() { + return render( + + + , + ); + } + + /** The cell values written by the last setMultipleRecordsCellValues mutation. */ + function lastWrite(): {tableId: string; recordId: string; cellValues: any} { + const writes = mutations.filter( + (mutation) => mutation.type === MutationTypes.SET_MULTIPLE_RECORDS_CELL_VALUES, + ); + const last = writes[writes.length - 1] as any; + return { + tableId: last.tableId, + recordId: last.records[0].id, + cellValues: last.records[0].cellValuesByFieldId, + }; + } + + it('renders both tables with all their records', async () => { + renderApp(); + + expect(await screen.findByText('Tasks (3 records)')).toBeInTheDocument(); + expect(await screen.findByText('Field testing (2 records)')).toBeInTheDocument(); + // Record names also appear in linked-record editors, so query headings. + expect(screen.getByRole('heading', {name: 'Buy groceries'})).toBeInTheDocument(); + expect( + screen.getByRole('heading', {name: 'Sample row 1 — all field types'}), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', {name: 'Sample row 2 — edge values'}), + ).toBeInTheDocument(); + }); + + it('renders an editor or read-only view for every field of every record', async () => { + renderApp(); + await screen.findByText('Tasks (3 records)'); + + for (const table of makeFixtureData().base.tables) { + for (const record of table.records) { + for (const field of table.fields) { + const controls = screen.getAllByLabelText( + new RegExp(`^${record.id}:${field.id}`), + ); + expect(controls.length).toBeGreaterThan(0); + } + } + } + }); + + it('edits text fields, writing a string', async () => { + const user = userEvent.setup(); + renderApp(); + const input = await screen.findByLabelText(`${ROW_ALL_ID}:fldSingleLineText`); + + await user.clear(input); + await user.type(input, 'Edited text'); + await user.tab(); + + expect(lastWrite().cellValues.fldSingleLineText).toBe('Edited text'); + expect(await screen.findByDisplayValue('Edited text')).toBeInTheDocument(); + }); + + it('edits number-family fields, writing numbers', async () => { + const user = userEvent.setup(); + renderApp(); + + const cases: Array<[string, string, number]> = [ + ['fldNumberPrecisi3', '3.14', 3.14], + ['fldPercentPrecis2', '0.75', 0.75], + ['fldCurrencyEURPre', '42.5', 42.5], + ['fldDurationHMmSs', '7200', 7200], + ['fldRating', '5', 5], + ]; + for (const [fieldId, typed, expected] of cases) { + const input = await screen.findByLabelText(`${ROW_ALL_ID}:${fieldId}`); + await user.clear(input); + await user.type(input, typed); + await user.tab(); + expect(lastWrite().cellValues[fieldId]).toBe(expected); + } + }); + + it('toggles a checkbox, writing a boolean', async () => { + const user = userEvent.setup(); + renderApp(); + const checkbox = await screen.findByLabelText(`${ROW_ALL_ID}:fldCheckbox`); + + expect(checkbox).toBeChecked(); + await user.click(checkbox); + + expect(lastWrite().cellValues.fldCheckbox).toBe(null); + expect(checkbox).not.toBeChecked(); + }); + + it('changes a single select, writing {id}', async () => { + const user = userEvent.setup(); + renderApp(); + const select = await screen.findByLabelText(`${ROW_ALL_ID}:fldSingleSelectAl`); + + await user.selectOptions(select, 'selRedBright'); + + expect(lastWrite().cellValues.fldSingleSelectAl).toEqual({id: 'selRedBright'}); + }); + + it('toggles multiple selects, writing Array<{id}>', async () => { + const user = userEvent.setup(); + renderApp(); + const choice = await screen.findByLabelText( + `${ROW_EDGE_ID}:fldMultipleSelect:selBlueBright`, + ); + + await user.click(choice); + + expect(lastWrite().cellValues.fldMultipleSelect).toEqual([ + {id: 'selGrayDark1'}, + {id: 'selBlueBright'}, + ]); + }); + + it('edits a date field, writing an ISO string', async () => { + const user = userEvent.setup(); + renderApp(); + const input = await screen.findByLabelText(`${ROW_ALL_ID}:fldDateIso`); + + await user.clear(input); + await user.type(input, '2027-01-15'); + await user.tab(); + + expect(lastWrite().cellValues.fldDateIso).toBe('2027-01-15'); + }); + + it('links records across tables, writing Array<{id, name}>', async () => { + const user = userEvent.setup(); + renderApp(); + // The Tasks table's "Field testing" link field: link "Do laundry" to + // sample row 2 as well. + const checkbox = await screen.findByLabelText( + `recBuyGroceries:fldFieldTesting:${ROW_EDGE_ID}`, + ); + + await user.click(checkbox); + + const write = lastWrite(); + expect(write.tableId).toBe(TASKS_TABLE_ID); + expect(write.cellValues.fldFieldTesting).toEqual([ + {id: ROW_ALL_ID, name: 'Sample row 1 — all field types'}, + {id: ROW_EDGE_ID, name: 'Sample row 2 — edge values'}, + ]); + expect(checkbox).toBeChecked(); + }); + + it('appends an attachment by URL, preserving existing attachments', async () => { + const user = userEvent.setup(); + renderApp(); + const input = await screen.findByLabelText(`${ROW_ALL_ID}:fldAttachments2:add`); + + await user.type(input, 'https://example.com/new-file.pdf'); + await user.tab(); + + const written = lastWrite().cellValues.fldAttachments2; + expect(written).toHaveLength(2); + expect(written[0].id).toBe('attihiDpq9V96It9d'); + expect(written[1]).toEqual({url: 'https://example.com/new-file.pdf'}); + }); + + it('sets collaborators, writing {id} shapes', async () => { + const user = userEvent.setup(); + renderApp(); + + const single = await screen.findByLabelText(`${ROW_EDGE_ID}:fldSingleCollabor`); + await user.type(single, 'usrPatChen0000000'); + await user.tab(); + expect(lastWrite().cellValues.fldSingleCollabor).toEqual({id: 'usrPatChen0000000'}); + + const multiple = await screen.findByLabelText(`${ROW_EDGE_ID}:fldMultipleCollab`); + await user.type(multiple, 'usrtestdriver0000,usrPatChen0000000'); + await user.tab(); + expect(lastWrite().cellValues.fldMultipleCollab).toEqual([ + {id: 'usrtestdriver0000'}, + {id: 'usrPatChen0000000'}, + ]); + }); + + it('renders read-only computed and metadata fields as text', async () => { + renderApp(); + await screen.findByText('Field testing (2 records)'); + + const expectations: Array<[string, string]> = [ + [`${ROW_ALL_ID}:fldFormula`, 'Sample row 1 — all field types — 42'], + [`${ROW_ALL_ID}:fldRollupTaskName`, 'Buy groceries, Water plants'], + [`${ROW_ALL_ID}:fldCountOfLinkedT`, '2'], + [`${ROW_ALL_ID}:fldLookupTaskName`, 'Buy groceries, Water plants'], + [`${ROW_ALL_ID}:fldBarcode`, '012345678905'], + [`${ROW_ALL_ID}:fldAutoNumber0000`, '1'], + [`${ROW_ALL_ID}:fldCreatedTime000`, '2026-07-22T10:04:40.000Z'], + [`${ROW_ALL_ID}:fldCreatedBy00000`, 'Test User'], + [`${ROW_ALL_ID}:fldButton00000000`, 'Open dashboard'], + [`${ROW_ALL_ID}:fldSyncSource0000`, 'Production CRM'], + [`${ROW_ALL_ID}:fldAiText00000000`, 'A row with every field type. (generated)'], + // Edge row: empty values render as a placeholder. + [`${ROW_EDGE_ID}:fldBarcode`, '(empty)'], + ]; + for (const [label, text] of expectations) { + expect(screen.getByLabelText(label)).toHaveTextContent(text); + } + }); +}); diff --git a/examples/field-types/test/fixtures.ts b/examples/field-types/test/fixtures.ts new file mode 100644 index 0000000..f443feb --- /dev/null +++ b/examples/field-types/test/fixtures.ts @@ -0,0 +1,144 @@ +/** + * The base fixture (fixtures/field-types.ts, generated from a real base) + * covers every field type the Airtable Meta API can create. Eight read-only + * types cannot be created that way — autoNumber, button, createdTime, + * lastModifiedTime, createdBy, lastModifiedBy, externalSyncSource, aiText — + * so this module grafts them onto the "Field testing" table with cell values + * in each type's documented read format, giving the suite coverage of the + * complete FieldType enum. + */ +import {type FixtureData} from '@usdr/airtable-interface-testing'; +import baseFixtureData from '../../../fixtures/field-types'; + +export const FIELD_TESTING_TABLE_ID = 'tblFieldTesting'; +export const TASKS_TABLE_ID = 'tblTasks'; +export const ROW_ALL_ID = 'recSampleRow1AllF'; +export const ROW_EDGE_ID = 'recSampleRow2Edge'; + +const TEST_USER = { + id: 'usrtestdriver0000', + email: 'test.user@example.com', + name: 'Test User', +}; + +const READ_ONLY_FIELDS = [ + { + id: 'fldAutoNumber0000', + name: 'Auto number', + description: null, + type: 'autoNumber', + options: null, + }, + { + id: 'fldCreatedTime000', + name: 'Created time', + description: null, + type: 'createdTime', + options: { + result: { + type: 'dateTime', + options: { + dateFormat: {name: 'iso', format: 'YYYY-MM-DD'}, + timeFormat: {name: '24hour', format: 'HH:mm'}, + timeZone: 'utc', + }, + }, + }, + }, + { + id: 'fldModifiedTime00', + name: 'Last modified time', + description: null, + type: 'lastModifiedTime', + options: { + isValid: true, + referencedFieldIds: ['fldSingleLineText'], + result: { + type: 'dateTime', + options: { + dateFormat: {name: 'iso', format: 'YYYY-MM-DD'}, + timeFormat: {name: '24hour', format: 'HH:mm'}, + timeZone: 'utc', + }, + }, + }, + }, + { + id: 'fldCreatedBy00000', + name: 'Created by', + description: null, + type: 'createdBy', + options: {choices: [TEST_USER]}, + }, + { + id: 'fldModifiedBy0000', + name: 'Last modified by', + description: null, + type: 'lastModifiedBy', + options: {referencedFieldIds: ['fldSingleLineText'], choices: [TEST_USER]}, + }, + { + id: 'fldButton00000000', + name: 'Button', + description: null, + type: 'button', + options: null, + }, + { + id: 'fldSyncSource0000', + name: 'Sync source', + description: null, + type: 'externalSyncSource', + options: { + choices: [{id: 'sync0000000000001', name: 'Production CRM', color: 'blueLight2'}], + }, + }, + { + id: 'fldAiText00000000', + name: 'AI text', + description: null, + type: 'aiText', + options: { + prompt: ['Summarize ', {field: {fieldId: 'fldSingleLineText'}}], + referencedFieldIds: ['fldSingleLineText'], + }, + }, +]; + +const READ_ONLY_CELL_VALUES: {[recordId: string]: {[fieldId: string]: unknown}} = { + [ROW_ALL_ID]: { + fldAutoNumber0000: 1, + fldCreatedTime000: '2026-07-22T10:04:40.000Z', + fldModifiedTime00: '2026-08-01T11:12:03.000Z', + fldCreatedBy00000: TEST_USER, + fldModifiedBy0000: TEST_USER, + fldButton00000000: {label: 'Open dashboard', url: 'https://example.com/dashboard'}, + fldSyncSource0000: {id: 'sync0000000000001', name: 'Production CRM', color: 'blueLight2'}, + fldAiText00000000: {state: 'generated', value: 'A row with every field type.', isStale: false}, + }, + [ROW_EDGE_ID]: { + fldAutoNumber0000: 2, + fldCreatedTime000: '2026-08-01T11:12:03.000Z', + fldModifiedTime00: '2026-08-01T11:12:03.000Z', + fldCreatedBy00000: TEST_USER, + // Edge row: button URL formula gone invalid, errored AI generation. + fldButton00000000: {label: 'Open dashboard', url: null}, + fldAiText00000000: {state: 'error', value: '', isStale: true, errorType: 'PROVIDER_ERROR'}, + }, +}; + +export function makeFixtureData(): FixtureData { + const fixtureData: FixtureData = JSON.parse(JSON.stringify(baseFixtureData)); + const fieldTestingTable = fixtureData.base.tables.find( + (table) => table.id === FIELD_TESTING_TABLE_ID, + ); + if (!fieldTestingTable) { + throw new Error('fixtures/field-types.ts no longer contains the Field testing table'); + } + + fieldTestingTable.fields.push(...READ_ONLY_FIELDS); + for (const record of fieldTestingTable.records) { + Object.assign(record.cellValuesByFieldId, READ_ONLY_CELL_VALUES[record.id] ?? {}); + } + return fixtureData; +} diff --git a/examples/field-types/test/format_coverage.test.tsx b/examples/field-types/test/format_coverage.test.tsx new file mode 100644 index 0000000..80bd9fe --- /dev/null +++ b/examples/field-types/test/format_coverage.test.tsx @@ -0,0 +1,225 @@ +/** + * Smoke test for every cell value format. + * + * Iterates the SDK's own FieldType enum and asserts, for each member: + * 1. the fixture contains at least one field of that type; + * 2. every non-empty cell value read through the real SDK + * (record.getCellValue) matches that type's documented "Cell read + * format" (https://airtable.com/developers/interface-extensions/api/FieldType); + * 3. at least one non-empty sample exists, so a validator can't pass + * vacuously. + * + * If Airtable adds a FieldType enum member, assertion 1 fails until the + * fixture covers it — that's deliberate. + */ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import {FieldType} from '@airtable/blocks/interface/models'; +import {useBase, useRecords} from '@airtable/blocks/interface/ui'; +import TestDriver from '@usdr/airtable-interface-testing'; +import {makeFixtureData} from './fixtures'; + +/** + * Collect the Record models of every table through the real `useRecords` + * hook. (The published interface-alpha build has no `table.selectRecords`; + * its `useRecords` reads the record store synchronously, so rendering is the + * portable way to obtain records on both current SDK builds.) + */ +async function collectRecordsByTableAsync( + testDriver: TestDriver, +): Promise>> { + const collected = new Map>(); + + function Collector({onRecords}: {onRecords: (tableId: string, records: Array) => void}) { + const base = useBase(); + return ( +
    + {base.tables.map((table: any) => ( + + ))} + collector-done +
    + ); + } + function TableCollector({ + table, + onRecords, + }: { + table: any; + onRecords: (tableId: string, records: Array) => void; + }) { + const records = useRecords(table); + onRecords(table.id, records); + return null; + } + + const view = render( + + collected.set(tableId, records)} /> + , + ); + await screen.findByText('collector-done'); + view.unmount(); + return collected; +} + +const isString = (value: unknown): boolean => typeof value === 'string'; +const isNumber = (value: unknown): boolean => typeof value === 'number' && !Number.isNaN(value); +const isCollaborator = (value: unknown): boolean => { + const collaborator = value as {id?: unknown; email?: unknown}; + return ( + typeof collaborator === 'object' && + collaborator !== null && + typeof collaborator.id === 'string' && + typeof collaborator.email === 'string' + ); +}; +const isSelectChoice = (value: unknown): boolean => { + const choice = value as {id?: unknown; name?: unknown}; + return ( + typeof choice === 'object' && + choice !== null && + typeof choice.id === 'string' && + typeof choice.name === 'string' + ); +}; +const isArrayOf = + (item: (value: unknown) => boolean) => + (value: unknown): boolean => + Array.isArray(value) && value.every(item); +const isIsoDate = (value: unknown): boolean => + isString(value) && /^\d{4}-\d{2}-\d{2}$/.test(value as string); +const isIsoDateTime = (value: unknown): boolean => + isString(value) && !Number.isNaN(Date.parse(value as string)); + +/** One validator per FieldType, implementing the documented cell READ format. */ +const CELL_READ_VALIDATORS: {[fieldType: string]: (value: unknown) => boolean} = { + [FieldType.SINGLE_LINE_TEXT]: isString, + [FieldType.MULTILINE_TEXT]: isString, + [FieldType.RICH_TEXT]: isString, + [FieldType.EMAIL]: isString, + [FieldType.URL]: isString, + [FieldType.PHONE_NUMBER]: isString, + [FieldType.NUMBER]: isNumber, + [FieldType.PERCENT]: isNumber, + [FieldType.CURRENCY]: isNumber, + [FieldType.DURATION]: isNumber, + [FieldType.RATING]: isNumber, + [FieldType.AUTO_NUMBER]: isNumber, + [FieldType.COUNT]: isNumber, + [FieldType.CHECKBOX]: (value) => value === true, + [FieldType.DATE]: isIsoDate, + [FieldType.DATE_TIME]: isIsoDateTime, + [FieldType.CREATED_TIME]: isIsoDateTime, + [FieldType.LAST_MODIFIED_TIME]: isIsoDateTime, + [FieldType.SINGLE_SELECT]: isSelectChoice, + [FieldType.MULTIPLE_SELECTS]: isArrayOf(isSelectChoice), + [FieldType.EXTERNAL_SYNC_SOURCE]: isSelectChoice, + [FieldType.SINGLE_COLLABORATOR]: isCollaborator, + [FieldType.CREATED_BY]: isCollaborator, + [FieldType.LAST_MODIFIED_BY]: isCollaborator, + [FieldType.MULTIPLE_COLLABORATORS]: isArrayOf(isCollaborator), + [FieldType.MULTIPLE_RECORD_LINKS]: isArrayOf((link) => { + const record = link as {id?: unknown; name?: unknown}; + return typeof record.id === 'string' && typeof record.name === 'string'; + }), + [FieldType.MULTIPLE_ATTACHMENTS]: isArrayOf((attachment) => { + const file = attachment as {id?: unknown; url?: unknown; filename?: unknown}; + return ( + typeof file.id === 'string' && + typeof file.url === 'string' && + typeof file.filename === 'string' + ); + }), + [FieldType.MULTIPLE_LOOKUP_VALUES]: isArrayOf((entry) => { + const lookup = entry as {linkedRecordId?: unknown; value?: unknown}; + return ( + typeof lookup === 'object' && + lookup !== null && + typeof lookup.linkedRecordId === 'string' && + 'value' in lookup + ); + }), + [FieldType.BARCODE]: (value) => + typeof value === 'object' && + value !== null && + typeof (value as {text?: unknown}).text === 'string', + [FieldType.BUTTON]: (value) => { + const button = value as {label?: unknown; url?: unknown}; + return ( + typeof button === 'object' && + button !== null && + typeof button.label === 'string' && + (button.url === null || typeof button.url === 'string') + ); + }, + [FieldType.AI_TEXT]: (value) => { + const aiText = value as {state?: unknown; value?: unknown; isStale?: unknown}; + return ( + typeof aiText === 'object' && + aiText !== null && + ['empty', 'loading', 'generated', 'error'].includes(aiText.state as string) && + typeof aiText.value === 'string' && + typeof aiText.isStale === 'boolean' + ); + }, + // Formula and rollup values take the type of options.result; the fixture + // uses string-producing formulas. + [FieldType.FORMULA]: isString, + [FieldType.ROLLUP]: isString, +}; + +describe('FieldType coverage', () => { + const testDriver = new TestDriver(makeFixtureData()); + const allFieldTypes = Object.values(FieldType) as Array; + + it('has a validator for every FieldType enum member', () => { + for (const fieldType of allFieldTypes) { + expect(CELL_READ_VALIDATORS[fieldType]).toBeDefined(); + } + }); + + it.each(allFieldTypes)('fixture contains at least one %s field', (fieldType) => { + const fields = testDriver.base.tables.flatMap((table: any) => + table.fields.filter((field: any) => field.type === fieldType), + ); + expect(fields.length).toBeGreaterThan(0); + }); + + it.each(allFieldTypes)( + 'every %s cell value matches the documented read format', + async (fieldType) => { + const validate = CELL_READ_VALIDATORS[fieldType]; + const recordsByTable = await collectRecordsByTableAsync(testDriver); + let nonEmptySamples = 0; + + for (const table of testDriver.base.tables) { + const fields = table.fields.filter((field: any) => field.type === fieldType); + if (fields.length === 0) { + continue; + } + for (const record of recordsByTable.get(table.id) ?? []) { + for (const field of fields) { + const value = record.getCellValue(field.id); + if (value === null) { + continue; + } + if (Array.isArray(value) && value.length === 0) { + continue; + } + nonEmptySamples++; + if (!validate(value)) { + throw new Error( + `${table.name}.${field.name} (${fieldType}) cell value does ` + + `not match the documented read format: ` + + JSON.stringify(value), + ); + } + } + } + } + + expect(nonEmptySamples).toBeGreaterThan(0); + }, + ); +}); diff --git a/examples/field-types/tsconfig.json b/examples/field-types/tsconfig.json new file mode 100644 index 0000000..37a89c5 --- /dev/null +++ b/examples/field-types/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["jest", "@testing-library/jest-dom"] + }, + "include": ["src/**/*", "test/**/*", "../../fixtures/field-types.ts"] +} diff --git a/examples/todo-list/package.json b/examples/todo-list/package.json index 551fb88..aba8769 100644 --- a/examples/todo-list/package.json +++ b/examples/todo-list/package.json @@ -1,7 +1,7 @@ { "name": "@usdr/example-todo-list", "private": true, - "version": "0.2.0", + "version": "0.3.0", "description": "Example Airtable interface extension with an automated test suite using @usdr/airtable-interface-testing", "type": "module", "scripts": { diff --git a/fixtures/field-types.ts b/fixtures/field-types.ts new file mode 100644 index 0000000..e037e6b --- /dev/null +++ b/fixtures/field-types.ts @@ -0,0 +1,1548 @@ +// Generated by airtable-testing-fixtures. Re-run the CLI to refresh. +import {type FixtureData} from '@usdr/airtable-interface-testing'; + +export const fixtureData: FixtureData = { + "base": { + "id": "appTestFixtureSam", + "name": "Test fixture sample", + "collaborators": [ + { + "id": "usrtestdriver0000", + "name": "Test User", + "email": "test.user@example.com", + "isActive": true + }, + { + "id": "usrPatChen0000000", + "name": "Pat Chen", + "email": "pat.chen@example.com", + "isActive": true + } + ], + "tables": [ + { + "id": "tblTasks", + "name": "Tasks", + "description": "Sample data for the todo-list interface extension test suite (airtable-interface-extension-testing repo)", + "fields": [ + { + "id": "fldName", + "name": "Name", + "description": null, + "type": "singleLineText", + "options": null + }, + { + "id": "fldNotes", + "name": "Notes", + "description": null, + "type": "multilineText", + "options": null + }, + { + "id": "fldAssignee", + "name": "Assignee", + "description": null, + "type": "singleCollaborator", + "options": null + }, + { + "id": "fldStatus", + "name": "Status", + "description": null, + "type": "singleSelect", + "options": { + "choices": [ + { + "id": "selTodo", + "name": "Todo", + "color": "redLight2" + }, + { + "id": "selInProgress", + "name": "In progress", + "color": "yellowLight2" + }, + { + "id": "selDone", + "name": "Done", + "color": "greenLight2" + } + ] + } + }, + { + "id": "fldAttachments", + "name": "Attachments", + "description": null, + "type": "multipleAttachments", + "options": { + "isReversed": false + } + }, + { + "id": "fldDone", + "name": "Done", + "description": "Checkbox field targeted by the extension's doneField custom property", + "type": "checkbox", + "options": { + "icon": "check", + "color": "greenBright" + } + }, + { + "id": "fldFieldTesting", + "name": "Field testing", + "description": null, + "type": "multipleRecordLinks", + "options": { + "linkedTableId": "tblFieldTesting", + "isReversed": false, + "prefersSingleRecordLink": false, + "inverseLinkFieldId": "fldLinkToTasks" + } + } + ], + "records": [ + { + "id": "recDoLaundry", + "createdTime": "2026-08-01T11:12:03.000Z", + "cellValuesByFieldId": { + "fldName": "Do laundry", + "fldStatus": { + "id": "selDone", + "name": "Done", + "color": "greenLight2" + }, + "fldFieldTesting": [ + { + "id": "recSampleRow2Edge", + "name": "Sample row 2 — edge values" + } + ], + "fldDone": true + } + }, + { + "id": "recWaterPlants", + "createdTime": "2026-08-01T11:12:03.000Z", + "cellValuesByFieldId": { + "fldName": "Water plants", + "fldStatus": { + "id": "selTodo", + "name": "Todo", + "color": "redLight2" + }, + "fldFieldTesting": [ + { + "id": "recSampleRow1AllF", + "name": "Sample row 1 — all field types" + } + ] + } + }, + { + "id": "recBuyGroceries", + "createdTime": "2026-08-01T11:12:03.000Z", + "cellValuesByFieldId": { + "fldNotes": "Milk, eggs, bread", + "fldName": "Buy groceries", + "fldStatus": { + "id": "selTodo", + "name": "Todo", + "color": "redLight2" + }, + "fldFieldTesting": [ + { + "id": "recSampleRow1AllF", + "name": "Sample row 1 — all field types" + } + ] + } + } + ] + }, + { + "id": "tblFieldTesting", + "name": "Field testing", + "description": "Every creatable Airtable field type and full option permutations, for generating a fixture that mocks all field types. Built via the Airtable connector API (types it cannot create — autoNumber, button, createdTime, lastModifiedTime, createdBy, lastModifiedBy, externalSyncSource, aiText — are not present).", + "fields": [ + { + "id": "fldName2", + "name": "Name", + "description": null, + "type": "singleLineText", + "options": null + }, + { + "id": "fldSingleLineText", + "name": "Single line text", + "description": null, + "type": "singleLineText", + "options": null + }, + { + "id": "fldEmail", + "name": "Email", + "description": null, + "type": "email", + "options": null + }, + { + "id": "fldURL", + "name": "URL", + "description": null, + "type": "url", + "options": null + }, + { + "id": "fldMultilineTextL", + "name": "Multiline text (long text)", + "description": null, + "type": "multilineText", + "options": null + }, + { + "id": "fldRichTextLongTe", + "name": "Rich text (long text + rich formatting)", + "description": null, + "type": "richText", + "options": null + }, + { + "id": "fldPhoneNumber", + "name": "Phone number", + "description": null, + "type": "phoneNumber", + "options": null + }, + { + "id": "fldBarcode", + "name": "Barcode", + "description": null, + "type": "barcode", + "options": null + }, + { + "id": "fldAttachments2", + "name": "Attachments", + "description": null, + "type": "multipleAttachments", + "options": { + "isReversed": false + } + }, + { + "id": "fldSingleCollabor", + "name": "Single collaborator", + "description": null, + "type": "singleCollaborator", + "options": null + }, + { + "id": "fldMultipleCollab", + "name": "Multiple collaborators", + "description": null, + "type": "multipleCollaborators", + "options": null + }, + { + "id": "fldNumberPrecisio", + "name": "Number precision 0", + "description": null, + "type": "number", + "options": { + "precision": 0 + } + }, + { + "id": "fldNumberPrecisi2", + "name": "Number precision 1", + "description": null, + "type": "number", + "options": { + "precision": 1 + } + }, + { + "id": "fldNumberPrecisi3", + "name": "Number precision 2", + "description": null, + "type": "number", + "options": { + "precision": 2 + } + }, + { + "id": "fldNumberPrecisi4", + "name": "Number precision 3", + "description": null, + "type": "number", + "options": { + "precision": 3 + } + }, + { + "id": "fldNumberPrecisi5", + "name": "Number precision 4", + "description": null, + "type": "number", + "options": { + "precision": 4 + } + }, + { + "id": "fldNumberPrecisi6", + "name": "Number precision 5", + "description": null, + "type": "number", + "options": { + "precision": 5 + } + }, + { + "id": "fldNumberPrecisi7", + "name": "Number precision 6", + "description": null, + "type": "number", + "options": { + "precision": 6 + } + }, + { + "id": "fldNumberPrecisi8", + "name": "Number precision 7", + "description": null, + "type": "number", + "options": { + "precision": 7 + } + }, + { + "id": "fldNumberPrecisi9", + "name": "Number precision 8", + "description": null, + "type": "number", + "options": { + "precision": 8 + } + }, + { + "id": "fldPercentPrecisi", + "name": "Percent precision 0", + "description": null, + "type": "percent", + "options": { + "precision": 0 + } + }, + { + "id": "fldPercentPrecis2", + "name": "Percent precision 1", + "description": null, + "type": "percent", + "options": { + "precision": 1 + } + }, + { + "id": "fldPercentPrecis3", + "name": "Percent precision 2", + "description": null, + "type": "percent", + "options": { + "precision": 2 + } + }, + { + "id": "fldPercentPrecis4", + "name": "Percent precision 3", + "description": null, + "type": "percent", + "options": { + "precision": 3 + } + }, + { + "id": "fldPercentPrecis5", + "name": "Percent precision 4", + "description": null, + "type": "percent", + "options": { + "precision": 4 + } + }, + { + "id": "fldPercentPrecis6", + "name": "Percent precision 5", + "description": null, + "type": "percent", + "options": { + "precision": 5 + } + }, + { + "id": "fldPercentPrecis7", + "name": "Percent precision 6", + "description": null, + "type": "percent", + "options": { + "precision": 6 + } + }, + { + "id": "fldPercentPrecis8", + "name": "Percent precision 7", + "description": null, + "type": "percent", + "options": { + "precision": 7 + } + }, + { + "id": "fldPercentPrecis9", + "name": "Percent precision 8", + "description": null, + "type": "percent", + "options": { + "precision": 8 + } + }, + { + "id": "fldCurrencyPrecis", + "name": "Currency $ precision 0", + "description": null, + "type": "currency", + "options": { + "precision": 0, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci2", + "name": "Currency $ precision 1", + "description": null, + "type": "currency", + "options": { + "precision": 1, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci3", + "name": "Currency $ precision 2", + "description": null, + "type": "currency", + "options": { + "precision": 2, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci4", + "name": "Currency $ precision 3", + "description": null, + "type": "currency", + "options": { + "precision": 3, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci5", + "name": "Currency $ precision 4", + "description": null, + "type": "currency", + "options": { + "precision": 4, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci6", + "name": "Currency $ precision 5", + "description": null, + "type": "currency", + "options": { + "precision": 5, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci7", + "name": "Currency $ precision 6", + "description": null, + "type": "currency", + "options": { + "precision": 6, + "symbol": "$" + } + }, + { + "id": "fldCurrencyPreci8", + "name": "Currency $ precision 7", + "description": null, + "type": "currency", + "options": { + "precision": 7, + "symbol": "$" + } + }, + { + "id": "fldCurrencyEURPre", + "name": "Currency EUR precision 2", + "description": null, + "type": "currency", + "options": { + "precision": 2, + "symbol": "€" + } + }, + { + "id": "fldCurrencyGBPPre", + "name": "Currency GBP precision 2", + "description": null, + "type": "currency", + "options": { + "precision": 2, + "symbol": "£" + } + }, + { + "id": "fldCurrencyJPYPre", + "name": "Currency JPY precision 0", + "description": null, + "type": "currency", + "options": { + "precision": 0, + "symbol": "¥" + } + }, + { + "id": "fldDurationHMm", + "name": "Duration h:mm", + "description": null, + "type": "duration", + "options": { + "durationFormat": "h:mm" + } + }, + { + "id": "fldDurationHMmSs", + "name": "Duration h:mm:ss", + "description": null, + "type": "duration", + "options": { + "durationFormat": "h:mm:ss" + } + }, + { + "id": "fldDurationHMmSsS", + "name": "Duration h:mm:ss.S", + "description": null, + "type": "duration", + "options": { + "durationFormat": "h:mm:ss.S" + } + }, + { + "id": "fldDurationHMmSs2", + "name": "Duration h:mm:ss.SS", + "description": null, + "type": "duration", + "options": { + "durationFormat": "h:mm:ss.SS" + } + }, + { + "id": "fldDurationHMmSs3", + "name": "Duration h:mm:ss.SSS", + "description": null, + "type": "duration", + "options": { + "durationFormat": "h:mm:ss.SSS" + } + }, + { + "id": "fldDateLocal", + "name": "Date local", + "description": null, + "type": "date", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + } + } + }, + { + "id": "fldDateFriendly", + "name": "Date friendly", + "description": null, + "type": "date", + "options": { + "dateFormat": { + "name": "friendly", + "format": "LL" + } + } + }, + { + "id": "fldDateUs", + "name": "Date us", + "description": null, + "type": "date", + "options": { + "dateFormat": { + "name": "us", + "format": "M/D/YYYY" + } + } + }, + { + "id": "fldDateEuropean", + "name": "Date european", + "description": null, + "type": "date", + "options": { + "dateFormat": { + "name": "european", + "format": "D/M/YYYY" + } + } + }, + { + "id": "fldDateIso", + "name": "Date iso", + "description": null, + "type": "date", + "options": { + "dateFormat": { + "name": "iso", + "format": "YYYY-MM-DD" + } + } + }, + { + "id": "fldDateTimeLocal1", + "name": "DateTime local 12hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeLocal2", + "name": "DateTime local 24hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeFriend", + "name": "DateTime friendly 12hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "friendly", + "format": "LL" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeFrien2", + "name": "DateTime friendly 24hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "friendly", + "format": "LL" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeUs12ho", + "name": "DateTime us 12hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "us", + "format": "M/D/YYYY" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeUs24ho", + "name": "DateTime us 24hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "us", + "format": "M/D/YYYY" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeEurope", + "name": "DateTime european 12hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "european", + "format": "D/M/YYYY" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeEurop2", + "name": "DateTime european 24hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "european", + "format": "D/M/YYYY" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeIso12h", + "name": "DateTime iso 12hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "iso", + "format": "YYYY-MM-DD" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeIso24h", + "name": "DateTime iso 24hour UTC", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "iso", + "format": "YYYY-MM-DD" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "utc" + } + }, + { + "id": "fldDateTimeIso242", + "name": "DateTime iso 24hour America/New_York", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "iso", + "format": "YYYY-MM-DD" + }, + "timeFormat": { + "name": "24hour", + "format": "HH:mm" + }, + "timeZone": "America/New_York" + } + }, + { + "id": "fldDateTimeUs12h2", + "name": "DateTime us 12hour Europe/London", + "description": null, + "type": "dateTime", + "options": { + "dateFormat": { + "name": "us", + "format": "M/D/YYYY" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "Europe/London" + } + }, + { + "id": "fldSingleSelectAl", + "name": "Single select (all colors)", + "description": null, + "type": "singleSelect", + "options": { + "choices": [ + { + "id": "selBlueLight2", + "name": "blueLight2", + "color": "blueLight2" + }, + { + "id": "selCyanLight2", + "name": "cyanLight2", + "color": "cyanLight2" + }, + { + "id": "selTealLight2", + "name": "tealLight2", + "color": "tealLight2" + }, + { + "id": "selGreenLight2", + "name": "greenLight2", + "color": "greenLight2" + }, + { + "id": "selYellowLight2", + "name": "yellowLight2", + "color": "yellowLight2" + }, + { + "id": "selOrangeLight2", + "name": "orangeLight2", + "color": "orangeLight2" + }, + { + "id": "selRedLight2", + "name": "redLight2", + "color": "redLight2" + }, + { + "id": "selPinkLight2", + "name": "pinkLight2", + "color": "pinkLight2" + }, + { + "id": "selPurpleLight2", + "name": "purpleLight2", + "color": "purpleLight2" + }, + { + "id": "selGrayLight2", + "name": "grayLight2", + "color": "grayLight2" + }, + { + "id": "selBlueLight1", + "name": "blueLight1", + "color": "blueLight1" + }, + { + "id": "selCyanLight1", + "name": "cyanLight1", + "color": "cyanLight1" + }, + { + "id": "selTealLight1", + "name": "tealLight1", + "color": "tealLight1" + }, + { + "id": "selGreenLight1", + "name": "greenLight1", + "color": "greenLight1" + }, + { + "id": "selYellowLight1", + "name": "yellowLight1", + "color": "yellowLight1" + }, + { + "id": "selOrangeLight1", + "name": "orangeLight1", + "color": "orangeLight1" + }, + { + "id": "selRedLight1", + "name": "redLight1", + "color": "redLight1" + }, + { + "id": "selPinkLight1", + "name": "pinkLight1", + "color": "pinkLight1" + }, + { + "id": "selPurpleLight1", + "name": "purpleLight1", + "color": "purpleLight1" + }, + { + "id": "selGrayLight1", + "name": "grayLight1", + "color": "grayLight1" + }, + { + "id": "selBlueBright", + "name": "blueBright", + "color": "blueBright" + }, + { + "id": "selCyanBright", + "name": "cyanBright", + "color": "cyanBright" + }, + { + "id": "selTealBright", + "name": "tealBright", + "color": "tealBright" + }, + { + "id": "selGreenBright", + "name": "greenBright", + "color": "greenBright" + }, + { + "id": "selYellowBright", + "name": "yellowBright", + "color": "yellowBright" + }, + { + "id": "selOrangeBright", + "name": "orangeBright", + "color": "orangeBright" + }, + { + "id": "selRedBright", + "name": "redBright", + "color": "redBright" + }, + { + "id": "selPinkBright", + "name": "pinkBright", + "color": "pinkBright" + }, + { + "id": "selPurpleBright", + "name": "purpleBright", + "color": "purpleBright" + }, + { + "id": "selGrayBright", + "name": "grayBright", + "color": "grayBright" + }, + { + "id": "selBlueDark1", + "name": "blueDark1", + "color": "blueDark1" + }, + { + "id": "selCyanDark1", + "name": "cyanDark1", + "color": "cyanDark1" + }, + { + "id": "selTealDark1", + "name": "tealDark1", + "color": "tealDark1" + }, + { + "id": "selGreenDark1", + "name": "greenDark1", + "color": "greenDark1" + }, + { + "id": "selYellowDark1", + "name": "yellowDark1", + "color": "yellowDark1" + }, + { + "id": "selOrangeDark1", + "name": "orangeDark1", + "color": "orangeDark1" + }, + { + "id": "selRedDark1", + "name": "redDark1", + "color": "redDark1" + }, + { + "id": "selPinkDark1", + "name": "pinkDark1", + "color": "pinkDark1" + }, + { + "id": "selPurpleDark1", + "name": "purpleDark1", + "color": "purpleDark1" + }, + { + "id": "selGrayDark1", + "name": "grayDark1", + "color": "grayDark1" + } + ] + } + }, + { + "id": "fldMultipleSelect", + "name": "Multiple selects (all colors)", + "description": null, + "type": "multipleSelects", + "options": { + "choices": [ + { + "id": "selBlueLight2", + "name": "blueLight2", + "color": "blueLight2" + }, + { + "id": "selCyanLight2", + "name": "cyanLight2", + "color": "cyanLight2" + }, + { + "id": "selTealLight2", + "name": "tealLight2", + "color": "tealLight2" + }, + { + "id": "selGreenLight2", + "name": "greenLight2", + "color": "greenLight2" + }, + { + "id": "selYellowLight2", + "name": "yellowLight2", + "color": "yellowLight2" + }, + { + "id": "selOrangeLight2", + "name": "orangeLight2", + "color": "orangeLight2" + }, + { + "id": "selRedLight2", + "name": "redLight2", + "color": "redLight2" + }, + { + "id": "selPinkLight2", + "name": "pinkLight2", + "color": "pinkLight2" + }, + { + "id": "selPurpleLight2", + "name": "purpleLight2", + "color": "purpleLight2" + }, + { + "id": "selGrayLight2", + "name": "grayLight2", + "color": "grayLight2" + }, + { + "id": "selBlueLight1", + "name": "blueLight1", + "color": "blueLight1" + }, + { + "id": "selCyanLight1", + "name": "cyanLight1", + "color": "cyanLight1" + }, + { + "id": "selTealLight1", + "name": "tealLight1", + "color": "tealLight1" + }, + { + "id": "selGreenLight1", + "name": "greenLight1", + "color": "greenLight1" + }, + { + "id": "selYellowLight1", + "name": "yellowLight1", + "color": "yellowLight1" + }, + { + "id": "selOrangeLight1", + "name": "orangeLight1", + "color": "orangeLight1" + }, + { + "id": "selRedLight1", + "name": "redLight1", + "color": "redLight1" + }, + { + "id": "selPinkLight1", + "name": "pinkLight1", + "color": "pinkLight1" + }, + { + "id": "selPurpleLight1", + "name": "purpleLight1", + "color": "purpleLight1" + }, + { + "id": "selGrayLight1", + "name": "grayLight1", + "color": "grayLight1" + }, + { + "id": "selBlueBright", + "name": "blueBright", + "color": "blueBright" + }, + { + "id": "selCyanBright", + "name": "cyanBright", + "color": "cyanBright" + }, + { + "id": "selTealBright", + "name": "tealBright", + "color": "tealBright" + }, + { + "id": "selGreenBright", + "name": "greenBright", + "color": "greenBright" + }, + { + "id": "selYellowBright", + "name": "yellowBright", + "color": "yellowBright" + }, + { + "id": "selOrangeBright", + "name": "orangeBright", + "color": "orangeBright" + }, + { + "id": "selRedBright", + "name": "redBright", + "color": "redBright" + }, + { + "id": "selPinkBright", + "name": "pinkBright", + "color": "pinkBright" + }, + { + "id": "selPurpleBright", + "name": "purpleBright", + "color": "purpleBright" + }, + { + "id": "selGrayBright", + "name": "grayBright", + "color": "grayBright" + }, + { + "id": "selBlueDark1", + "name": "blueDark1", + "color": "blueDark1" + }, + { + "id": "selCyanDark1", + "name": "cyanDark1", + "color": "cyanDark1" + }, + { + "id": "selTealDark1", + "name": "tealDark1", + "color": "tealDark1" + }, + { + "id": "selGreenDark1", + "name": "greenDark1", + "color": "greenDark1" + }, + { + "id": "selYellowDark1", + "name": "yellowDark1", + "color": "yellowDark1" + }, + { + "id": "selOrangeDark1", + "name": "orangeDark1", + "color": "orangeDark1" + }, + { + "id": "selRedDark1", + "name": "redDark1", + "color": "redDark1" + }, + { + "id": "selPinkDark1", + "name": "pinkDark1", + "color": "pinkDark1" + }, + { + "id": "selPurpleDark1", + "name": "purpleDark1", + "color": "purpleDark1" + }, + { + "id": "selGrayDark1", + "name": "grayDark1", + "color": "grayDark1" + } + ] + } + }, + { + "id": "fldCheckbox", + "name": "Checkbox", + "description": null, + "type": "checkbox", + "options": { + "icon": "check", + "color": "greenBright" + } + }, + { + "id": "fldRating", + "name": "Rating", + "description": null, + "type": "rating", + "options": { + "icon": "star", + "max": 5, + "color": "yellowBright" + } + }, + { + "id": "fldLinkToTasks", + "name": "Link to Tasks", + "description": null, + "type": "multipleRecordLinks", + "options": { + "linkedTableId": "tblTasks", + "isReversed": false, + "prefersSingleRecordLink": false, + "inverseLinkFieldId": "fldFieldTesting" + } + }, + { + "id": "fldFormula", + "name": "Formula", + "description": null, + "type": "formula", + "options": { + "isValid": true, + "formula": "CONCATENATE({fldYsVPKCsYjpe0CR}, \" — \", {fldoa49kDgxU9Rltl})", + "referencedFieldIds": [ + "fldYsVPKCsYjpe0CR", + "fldoa49kDgxU9Rltl" + ], + "result": { + "type": "singleLineText" + } + } + }, + { + "id": "fldCountOfLinkedT", + "name": "Count (of linked Tasks)", + "description": null, + "type": "count", + "options": { + "isValid": true, + "recordLinkFieldId": "fldpIbvgXOSABybfI" + } + }, + { + "id": "fldLookupTaskName", + "name": "Lookup (Task name)", + "description": null, + "type": "multipleLookupValues", + "options": { + "isValid": true, + "recordLinkFieldId": "fldpIbvgXOSABybfI", + "fieldIdInLinkedTable": "fldNkrgyKkccJ9y9w", + "result": { + "type": "singleLineText" + } + } + }, + { + "id": "fldRollupTaskName", + "name": "Rollup (Task names joined)", + "description": null, + "type": "rollup", + "options": { + "isValid": true, + "recordLinkFieldId": "fldpIbvgXOSABybfI", + "fieldIdInLinkedTable": "fldNkrgyKkccJ9y9w", + "referencedFieldIds": [], + "result": { + "type": "singleLineText" + } + } + } + ], + "records": [ + { + "id": "recSampleRow2Edge", + "createdTime": "2026-08-01T11:27:30.000Z", + "cellValuesByFieldId": { + "fldSingleLineText": "Second row", + "fldSingleSelectAl": { + "id": "selGrayDark1", + "name": "grayDark1", + "color": "grayDark1" + }, + "fldRollupTaskName": "Do laundry", + "fldDurationHMm": 5400, + "fldLookupTaskName": [ + { + "linkedRecordId": "recDoLaundry", + "value": "Do laundry" + } + ], + "fldCountOfLinkedT": 1, + "fldMultipleSelect": [ + { + "id": "selGrayDark1", + "name": "grayDark1", + "color": "grayDark1" + } + ], + "fldPercentPrecis3": 1, + "fldRating": 2, + "fldCurrencyPreci3": 0, + "fldDateIso": "2000-01-01", + "fldName2": "Sample row 2 — edge values", + "fldDateTimeIso24h": "2000-01-01T00:00:00.000Z", + "fldFormula": "Sample row 2 — edge values — -7", + "fldNumberPrecisio": -7, + "fldLinkToTasks": [ + { + "id": "recDoLaundry", + "name": "Do laundry" + } + ] + } + }, + { + "id": "recSampleRow1AllF", + "createdTime": "2026-08-01T11:27:30.000Z", + "cellValuesByFieldId": { + "fldSingleLineText": "Sample single line text", + "fldPercentPrecis2": 0.425, + "fldSingleSelectAl": { + "id": "selBlueBright", + "name": "blueBright", + "color": "blueBright" + }, + "fldPercentPrecis7": 0.42424242, + "fldRollupTaskName": "Buy groceries, Water plants", + "fldAttachments2": [ + { + "id": "attihiDpq9V96It9d", + "width": 272, + "height": 92, + "url": "https://v5.airtableusercontent.com/v3/u/55/55/1785715200000/kocHYrJWCm8lIL9CfGJQHQ/7JN5PiGFN9Rt95BM-Mp-c0iv3wXmRU8TqBPuwVAqgyZIuZNIEVIVmLhwVk0NFYZkgO3-IXzAMuWsi_ZN-lkJEACqvxm1ODf3ltWzt3wplqVejBq1yI8PSxZ9zsWpz6XMPVTaKi6EJNxpIBZtMdsmrAeYYgexzNCa615GKHD6MvM/RfC1b6SPQEcNuS1bDAGfkV9T0WgAx9LyT09_w73Z6LQ", + "filename": "googlelogo.png", + "size": 5969, + "type": "image/png", + "thumbnails": { + "small": { + "url": "https://v5.airtableusercontent.com/v3/u/55/55/1785715200000/0cO9VksI6HQMylJ77luPBg/BIBfQU_6-wDWcGWhCjoDS80h0xAzhqSF-lRyvaVUFAIUqJfhcRaYiVqx85HhldjfKGrstfVpEZfcSL0UBmGbVwTXWVH5gTIq9Q2xudK8TFpIXhlLqEKAJOkAq5Y9usTmSO6_eiMvFOHQ--b--3EbxA/CAiutCaQryDmw9E3SZzIDcvgV-mbXGiOVUunzIoWn08", + "width": 106, + "height": 36 + }, + "large": { + "url": "https://v5.airtableusercontent.com/v3/u/55/55/1785715200000/4o-q7iQsZxw8X5o7hlpJ_A/xUwaVv9VfuBICSh6BJIwOR9peHETz45qXR2qExzrbW3kqpIRJaa_wsnvACpKy-MuepqOjjkZ_47npjv9x2hsvt2Z3jMHtqWE73E8tT90Gp3J8C20pguPdQNvPgAfzrXS1ZiuZ96NRssq4SIWC9dutA/ZuT_UcLJy5ZhpjI5GY9sXQhswywYUAayXQBC40KgGEA", + "width": 272, + "height": 92 + }, + "full": { + "url": "https://v5.airtableusercontent.com/v3/u/55/55/1785715200000/0l1ueKPbaGI7K-_WMUsOKA/r5OfeJqHK5F9jhx_XKDG4ESz7Rm3TGLLgG5EqxjJ0mKkKellR9Cv0PkyjvLe-tISusFsOJoUV3vf5wYoNPXpZDinKpdt0pmorg7FEPfREZPJqAPkY7cBZnNnGafqHSKtGt9gpSo_qWRMWWQEfC_H4A/ZZgzjUgCJCG_J8HA4uUXUQpFj0UlLVBmjExQasqvdRQ", + "width": 272, + "height": 92 + } + } + } + ], + "fldDateTimeIso12h": "2026-07-22T10:04:40.000Z", + "fldDurationHMmSsS": 3661.5, + "fldDateTimeLocal2": "2026-07-22T10:04:40.000Z", + "fldCurrencyEURPre": 19.99, + "fldCurrencyPreci2": 19.9, + "fldNumberPrecisi3": 42.42, + "fldDurationHMm": 3660, + "fldCurrencyPreci4": 19.999, + "fldDateTimeFrien2": "2026-07-22T10:04:40.000Z", + "fldLookupTaskName": [ + { + "linkedRecordId": "recBuyGroceries", + "value": "Buy groceries" + }, + { + "linkedRecordId": "recWaterPlants", + "value": "Water plants" + } + ], + "fldNumberPrecisi9": 42.42424242, + "fldPercentPrecisi": 0.42, + "fldCountOfLinkedT": 2, + "fldNumberPrecisi8": 42.4242425, + "fldPercentPrecis8": 0.424242425, + "fldCurrencyPrecis": 20, + "fldPhoneNumber": "+1 (555) 123-4567", + "fldMultilineTextL": "Line 1\nLine 2\nLine 3", + "fldDateTimeUs12h2": "2026-07-22T10:04:40.000Z", + "fldDateTimeFriend": "2026-07-22T10:04:40.000Z", + "fldMultipleSelect": [ + { + "id": "selBlueBright", + "name": "blueBright", + "color": "blueBright" + }, + { + "id": "selRedBright", + "name": "redBright", + "color": "redBright" + }, + { + "id": "selGreenLight2", + "name": "greenLight2", + "color": "greenLight2" + } + ], + "fldPercentPrecis3": 0.4242, + "fldNumberPrecisi5": 42.4242, + "fldNumberPrecisi7": 42.424242, + "fldPercentPrecis6": 0.4242425, + "fldDurationHMmSs2": 3661.55, + "fldDurationHMmSs3": 3661.555, + "fldPercentPrecis4": 0.42425, + "fldRating": 4, + "fldDateEuropean": "2026-07-22", + "fldCurrencyPreci3": 19.99, + "fldDateIso": "2026-07-22", + "fldCurrencyPreci6": 19.99999, + "fldName2": "Sample row 1 — all field types", + "fldSingleCollabor": { + "id": "usrtestdriver0000", + "email": "test.user@example.com", + "name": "Test User" + }, + "fldMultipleCollab": [ + { + "id": "usrtestdriver0000", + "email": "test.user@example.com", + "name": "Test User" + }, + { + "id": "usrPatChen0000000", + "email": "pat.chen@example.com", + "name": "Pat Chen" + } + ], + "fldDurationHMmSs": 3661, + "fldDateTimeIso24h": "2026-07-22T10:04:40.000Z", + "fldRichTextLongTe": "**Bold**, _italic_, and a list:\n- one\n- two\n", + "fldCurrencyPreci5": 19.9999, + "fldDateTimeLocal1": "2026-07-22T10:04:40.000Z", + "fldDateTimeEurope": "2026-07-22T10:04:40.000Z", + "fldBarcode": { + "text": "012345678905" + }, + "fldURL": "https://example.com", + "fldDateUs": "2026-07-22", + "fldDateTimeUs12ho": "2026-07-22T10:04:40.000Z", + "fldDateTimeUs24ho": "2026-07-22T10:04:40.000Z", + "fldNumberPrecisi6": 42.42425, + "fldFormula": "Sample row 1 — all field types — 42", + "fldDateTimeEurop2": "2026-07-22T10:04:40.000Z", + "fldNumberPrecisi2": 42.5, + "fldNumberPrecisio": 42, + "fldPercentPrecis9": 0.42424242425, + "fldDateLocal": "2026-07-22", + "fldLinkToTasks": [ + { + "id": "recBuyGroceries", + "name": "Buy groceries" + }, + { + "id": "recWaterPlants", + "name": "Water plants" + } + ], + "fldNumberPrecisi4": 42.425, + "fldDateFriendly": "2026-07-22", + "fldCurrencyPreci8": 19.9999999, + "fldPercentPrecis5": 0.424242, + "fldCurrencyJPYPre": 1999, + "fldCheckbox": true, + "fldCurrencyPreci7": 19.999999, + "fldCurrencyGBPPre": 19.99, + "fldDateTimeIso242": "2026-07-22T10:04:40.000Z", + "fldEmail": "test@example.com" + } + } + ] + } + ] + } +}; + +export default fixtureData; diff --git a/package-lock.json b/package-lock.json index 673be5d..5830234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "airtable-interface-extension-testing", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airtable-interface-extension-testing", - "version": "0.2.0", + "version": "0.3.0", "workspaces": [ "packages/*", "examples/*" @@ -23,9 +23,25 @@ "node": ">=20.19" } }, + "examples/field-types": { + "name": "@usdr/example-field-types", + "version": "0.3.0", + "dependencies": { + "@airtable/blocks": "interface-alpha", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@usdr/airtable-interface-testing": "*", + "typescript": "^5.6.0" + } + }, "examples/todo-list": { "name": "@usdr/example-todo-list", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@airtable/blocks": "interface-alpha", "react": "^19.1.0", @@ -4763,6 +4779,10 @@ "resolved": "packages/fixture-generator", "link": true }, + "node_modules/@usdr/example-field-types": { + "resolved": "examples/field-types", + "link": true + }, "node_modules/@usdr/example-todo-list": { "resolved": "examples/todo-list", "link": true @@ -9872,7 +9892,7 @@ }, "packages/fixture-generator": { "name": "@usdr/airtable-interface-testing-fixtures", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@inquirer/prompts": "^7.0.0" }, @@ -9897,7 +9917,7 @@ }, "packages/testing": { "name": "@usdr/airtable-interface-testing", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", diff --git a/package.json b/package.json index 0b888dd..a926eea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "airtable-interface-extension-testing", "private": true, - "version": "0.2.0", + "version": "0.3.0", "description": "Testing library and fixture tooling for Airtable interface extensions (interface-alpha Blocks SDK)", "type": "module", "workspaces": [ diff --git a/packages/fixture-generator/README.md b/packages/fixture-generator/README.md index aa326ae..8df7c3c 100644 --- a/packages/fixture-generator/README.md +++ b/packages/fixture-generator/README.md @@ -7,7 +7,7 @@ Generate [`FixtureData`](../testing/README.md#writing-fixture-data) for interfac Inside this repo the CLI is already linked, so `npx airtable-testing-fixtures` works from the repo root. From another project, install the tarball attached to a [GitHub Release](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases) — copy the link to `usdr-airtable-interface-testing-fixtures-.tgz` under **Assets**: ```bash -npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.2.0/usdr-airtable-interface-testing-fixtures-0.2.0.tgz +npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.3.0/usdr-airtable-interface-testing-fixtures-0.3.0.tgz ``` ## Usage diff --git a/packages/fixture-generator/package.json b/packages/fixture-generator/package.json index 877213a..7bcde4b 100644 --- a/packages/fixture-generator/package.json +++ b/packages/fixture-generator/package.json @@ -1,6 +1,6 @@ { "name": "@usdr/airtable-interface-testing-fixtures", - "version": "0.2.0", + "version": "0.3.0", "description": "Generate test fixture data for Airtable interface extensions from a real base via the Airtable REST API", "type": "module", "bin": { diff --git a/packages/fixture-generator/src/convert.ts b/packages/fixture-generator/src/convert.ts index 0b1e6d7..d0042e8 100644 --- a/packages/fixture-generator/src/convert.ts +++ b/packages/fixture-generator/src/convert.ts @@ -102,6 +102,17 @@ export function buildFixtureData( return choice; }); } + case 'multipleLookupValues': { + // The REST API flattens lookups to a plain array of values, + // losing which linked record each value came from — so the + // SDK's documented read format (Array<{linkedRecordId, + // value}>) cannot be reconstructed here. + warnOnce( + `lookup:${field.id}`, + `${tableName}.${field.name}: lookup values export in the REST API's flat-array shape; the SDK documents Array<{linkedRecordId, value}> — hand-edit the fixture if your extension reads linkedRecordId`, + ); + return value; + } case 'multipleRecordLinks': { if (!Array.isArray(value)) { return value; diff --git a/packages/fixture-generator/test/convert.test.ts b/packages/fixture-generator/test/convert.test.ts index cdc7319..7bda1e1 100644 --- a/packages/fixture-generator/test/convert.test.ts +++ b/packages/fixture-generator/test/convert.test.ts @@ -193,6 +193,46 @@ describe('buildFixtureData', () => { ]); }); + it('warns that lookup values keep the REST flat-array shape', () => { + const exports = makeExports(); + exports[0] = { + ...exports[0], + schema: { + ...TASKS_SCHEMA, + fields: [ + ...TASKS_SCHEMA.fields, + { + id: 'fldLookup00000000', + name: 'Project names', + type: 'multipleLookupValues', + options: {recordLinkFieldId: 'fldProject0000000'}, + }, + ], + }, + fieldIds: [...exports[0].fieldIds, 'fldLookup00000000'], + records: [ + { + ...exports[0].records[0], + fields: { + ...exports[0].records[0].fields, + fldLookup00000000: ['Fixture generator'], + }, + }, + ], + }; + const {fixtureData, warnings} = buildFixtureData( + {id: 'appExample0000000', name: 'Example'}, + exports, + ); + + // Passed through unchanged; the documented SDK read format needs + // linkedRecordId, which the REST payload does not carry. + expect(fixtureData.base.tables[0].records[0].cellValuesByFieldId.fldLookup00000000).toEqual( + ['Fixture generator'], + ); + expect(warnings.some((warning) => warning.includes('flat-array shape'))).toBe(true); + }); + it('requires the primary field to be exported', () => { const exports = makeExports(); exports[0] = {...exports[0], fieldIds: ['fldCount000000000']}; diff --git a/packages/testing/README.md b/packages/testing/README.md index 7dcd952..a4a5a38 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -27,7 +27,7 @@ The [example extension's test suite](../../examples/todo-list/test/app.test.tsx) This package isn't published to npm. Install the tarball from a [GitHub Release](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases): ```bash -npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.2.0/usdr-airtable-interface-testing-0.2.0.tgz @airtable/blocks@interface-alpha +npm install --save-dev https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases/download/v0.3.0/usdr-airtable-interface-testing-0.3.0.tgz @airtable/blocks@interface-alpha ``` **Finding the URL ---** on the [releases page](https://github.com/usdigitalresponse/airtable-interface-extension-testing/releases), open a release and copy the link to `usdr-airtable-interface-testing-.tgz` under **Assets**. Every release follows the same URL shape, so bumping the version in the command above works too: diff --git a/packages/testing/package.json b/packages/testing/package.json index 8b9af8b..53a2d25 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@usdr/airtable-interface-testing", - "version": "0.2.0", + "version": "0.3.0", "description": "Automated testing library for Airtable interface extensions (interface-alpha Blocks SDK)", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/testing/src/fixture_data.ts b/packages/testing/src/fixture_data.ts index b5f3948..934bb37 100644 --- a/packages/testing/src/fixture_data.ts +++ b/packages/testing/src/fixture_data.ts @@ -203,6 +203,9 @@ export function convertFixtureDataToSdkInitData(fixtureData: FixtureData): SdkIn isDevelopmentMode: false, blockInstallationId: DEFAULT_BLOCK_INSTALLATION_ID, isFirstRun: false, + // Fixtures declare multipleLookupValues cell values in the SDK's + // documented public read format: Array<{linkedRecordId, value}>. + isUsingNewLookupCellValueFormat: true, initialKvValuesByKey: fixtureData.globalConfig ?? {}, initialSearchParams: fixtureData.searchParams ?? {}, runContext: { diff --git a/packages/testing/src/sdk_types.ts b/packages/testing/src/sdk_types.ts index c74bb30..dc58856 100644 --- a/packages/testing/src/sdk_types.ts +++ b/packages/testing/src/sdk_types.ts @@ -138,7 +138,7 @@ export interface PageElementInQueryContainerBlockRunContext { isPageElementInEditMode: boolean; } -/** Mirrors interface/types/airtable_interface.ts (SdkInitData) */ +/** Mirrors interface/types/airtable_interface.ts (SdkInitData) + shared/types/airtable_interface_core.ts (SdkInitDataCore) */ export interface SdkInitData { isDevelopmentMode: boolean; blockInstallationId: BlockInstallationId; @@ -148,6 +148,13 @@ export interface SdkInitData { runContext: PageElementInQueryContainerBlockRunContext; baseData: BaseData; intentData: unknown; + /** + * When true, multipleLookupValues cell values use the SDK's documented + * public read format (`Array<{linkedRecordId, value}>`); when absent, the + * SDK expects hyperbase's internal `{linkedRecordIds, + * valuesByLinkedRecordId}` shape and throws on anything else. + */ + isUsingNewLookupCellValueFormat?: true; } /** Mirrors shared/types/base_core.ts (ModelChange) */