--- description: Airtable Interface Extensions development (v0.3.0, 2025-10-20) globs: alwaysApply: true --- You are developing an Interface Extension which extends Airtable's built-in Interfaces with custom UI to serve a specific need or use case. * The ONLY two import paths are `@airtable/blocks/interface/ui` and `@airtable/blocks/interface/models` * Import Airtable Blocks SDK hooks and functions (like `initializeBlock`, `useBase`, `useRecords`, `useCustomProperties`, `useColorScheme` and `expandRecord`) from '@airtable/blocks/interface/ui' NOT '@airtable/blocks/ui' * Import the `FieldType` enum from '@airtable/blocks/interface/models' NOT '@airtable/blocks/models' * Always import and use this enum when comparing field types. NEVER compare field.type against a string literal. * ONLY valid FieldType values: AI_TEXT, AUTO_NUMBER, BARCODE, BUTTON, CHECKBOX, COUNT, CREATED_BY, CREATED_TIME, CURRENCY, DATE, DATE_TIME, DURATION, EMAIL, EXTERNAL_SYNC_SOURCE, FORMULA, LAST_MODIFIED_BY, LAST_MODIFIED_TIME, MULTILINE_TEXT, MULTIPLE_ATTACHMENTS, MULTIPLE_COLLABORATORS, MULTIPLE_LOOKUP_VALUES, MULTIPLE_RECORD_LINKS, MULTIPLE_SELECTS, NUMBER, PERCENT, PHONE_NUMBER, RATING, RICH_TEXT, ROLLUP, SINGLE_COLLABORATOR, SINGLE_LINE_TEXT, SINGLE_SELECT, URL * Don't import any Airtable Blocks UI elements like `Box` as these are not supported in Interface Extensions * The entrypoint for an Interface Extension is `frontend/index.js` and you should focus your editing there (or on components that are then imported there) * The `frontend/index.js` file should conclude with an `initializeBlock` call that looks like: `initializeBlock({ interface: () => });` where `MyComponent` is the name of the root component to be rendered * To retrieve information about the current user, import the `useSession` hook which returns the current session. `session.currentUser` will provide attributes about the user: `email`, `id`, `name` and `profilePicUrl` (optional). * When working with single select or multiple select fields, if you ever need to use the color that corresponds to a specific choice/select option, use the `colorUtils.getHexForColor` SDK method from '@airtable/blocks/interface/ui' to convert it to a hex code. * Custom Elements can access multiple tables of Airtable data. Each table's data is available by: 1. Importing `useBase` and `useRecords` hooks 2. Calling `const base = useBase(); const table = base.getTableById(tableId); const records = useRecords(table);` or use custom properties to let users select tables (recommended) * When accessing tables, use custom properties (recommended) or `base.getTableById(tableId)` if you know the table ID (see and sections for best practices) * Always use `table.getFieldIfExists(string)` to get a field. May return null if the field was deleted or is not visible. Make sure to check for null. * DO NOT use `table.getField(string)`, `table.getFieldByName(string)`, or `table.getFieldById(string)` as these will throw errors * DO NOT pass hard-coded field names to `table.getFieldIfExists(string)`. Use custom properties instead (see ) * Example: `const nameField = table.getFieldIfExists('name');` is WRONG. Use a custom property instead. * Always check if field exists before calling `record.getCellValue(field)`. Will throw error if field doesn't exist or isn't visible. * Cell values for SELECT fields have type `{id: string, name: string, color: string}`. Render the `name` property, not the entire object. * Use `record.getCellValueAsString(field)` to just render the cell value as-is without needing to handle different field types * Airtable records returned by `useRecords(table)` may change without warning at any time, whether because records were created, edited or deleted, or the user's permissions were updated. * Depending on the configuration of the Interface Extension and the specific fields you're trying to edit, adding/editing/deleting records may not be allowed. * Check whether you are able to add record(s) BEFORE trying to add them by using `table.hasPermissionToCreateRecords(records?: ReadonlyArray<{ fields?: ObjectMap | void; }>) => boolean`. The more accurate the parameters you provide (e.g. the record and/or the specific fields you want to add), the more accurate the permission check will be, but none of the parameters are required. * Check whether you are able to edit record(s) in the way you intend to BEFORE trying to edit them by using `table.hasPermissionToUpdateRecords(records?: ReadonlyArray<{fields?: ObjectMap | void;id?: RecordId | void; }>) => boolean`. * Check whether you are able to delete record(s) BEFORE trying to delete them by using `table.hasPermissionToDeleteRecords(recordsOrRecordIds?: ReadonlyArray) => boolean` * To add records, use `table.createRecordAsync(fields: ObjectMap = {}) => Promise` for one record or `table.createRecordsAsync((records: ReadonlyArray<{ fields: ObjectMap; }>) => Promise>` for multiple records * To edit records, use `table.updateRecordAsync(recordOrRecordId: Record | RecordId, fields: ObjectMap) => Promise` for one record or `table.updateRecordsAsync(records: ReadonlyArray<{fields: ObjectMap; id: RecordId; }>) => Promise` for multiple records * To set values for fields with type `MULTIPLE_RECORD_LINKS`, you can use `record.fetchForeignRecordsAsync( field: Field, filterString: string ) => Promise<{ records: ReadonlyArray<{ displayName: string; id: RecordId; }>; }>` to return possible values for these fields. * Use the `filterString` property to search for record values based on user input. An empty `filterString` parameter will return an initial set of results that the user can then filter down. * To delete records, use `table.deleteRecordAsync(recordOrRecordId: Record | RecordId) => Promise` for one record or `table.deleteRecordsAsync(recordsOrRecordIds: ReadonlyArray) => Promise` for multiple records * When adding/editing/deleting multiple records, you may only add/edit/delete up to 50 records per call, and calls are rate-limited to 15 per second, so chunk your calls and `await` each one to avoid these limits * Table IDs (e.g., "tblXXXXXXXXXX") are stable and don't change if tables are renamed * Table names can change if users rename them, making name-based lookups fragile * When the user's instructions mention specific table names (e.g., "Projects", "Tasks", "Sprints"), create table custom properties for each table * Use `base.getTableByIdIfExists(tableId)` when you know the table ID, as IDs are more stable * Use `base.getTableByNameIfExists(tableName)` when you only have the table name * Always access tables via custom properties in your source code, not by hard-coded names or IDs * Example workflow: 1. Create table custom properties: `projectsTable`, `tasksTable` 2. Set defaults: `base.getTableByIdIfExists('tblABC123')` if you know the ID, or `base.getTableByNameIfExists('Projects')` as fallback 3. In source code, access via `customPropertyValueByKey.projectsTable` (not `base.getTableByName('Projects')`) * Avoid hardcoding table indices like `base.tables[1]` in your implementation code - use custom properties (recommended) or `base.getTableById(tableId)` if you know the table ID * When you need to access tables by position, you can use `base.tables` array, but custom properties are strongly recommended * To access a table by ID: use `base.getTableById(tableId)` or `base.getTableByIdIfExists(tableId)` (safer, returns null if not found) * ALWAYS use custom properties to allow users to select which tables to use * Use the 'table' custom property type to let users configure table selection: ```javascript { key: 'projectsTable', label: 'Projects Table', type: 'table', defaultValue: base.tables.find((table) => table.name.toLowerCase().includes('projects')), } ``` * Use custom properties to allow users to select tables, and access them via `customPropertyValueByKey` * Custom properties allow Airtable builders to configure properties of the Interface Extension on each Interface page it is used on * ALWAYS use custom properties to define required fields from the underlying Airtable data. DO NOT hard-code field names/ids into the source code. Make sure to provide a reasonable `defaultValue`. Also make sure to provide a `shouldFieldBeAllowed` function that returns a boolean indicating whether the field should be allowed. * To define custom properties: 1. Import the `useCustomProperties` hook from `@airtable/blocks/interface/ui`. 2. Define your properties in a function. This function receives the current `base` and returns an array of `BlockPageElementCustomProperty` objects. ``` type BlockPageElementCustomProperty = {key: string; label: string} & ( | {type: 'boolean'; defaultValue: boolean} | {type: 'string'; defaultValue?: string} | { type: 'enum'; possibleValues: Array<{value: string; label: string}>; defaultValue?: string; } | { type: 'field'; table: Table; shouldFieldBeAllowed?: (field: {id: FieldId; config: FieldConfig}) => boolean; // If not provided, all fields in the table will be shown in the dropdown. defaultValue?: Field; } | { type: 'table'; defaultValue?: Table; } ); ``` 3. Important: wrap the function in `useCallback` or define it outside of the component. This ensures a stable identity, which is important for memoization and for subscribing to schema changes correctly. 4. Call `useCustomProperties` with your function. It returns an object with: * `customPropertyValueByKey`: a mapping of each property's key to its current value. * `errorState`: if present, contains an error from trying to set up custom properties. * Custom properties should be used to define values that are required for the Interface Extension to work at all * Custom properties should be used to define required fields from the underlying Airtable data, to avoid hard-coding field names into the code of the Interface Extension * Make it easier for builders configuring the custom properties by filtering to only show fields with the relevant type (e.g. single select fields, number fields). To do this, within your function that is passed to `useCustomProperties`, access the current table using `base.getTableById(tableId)` or custom properties and filter the table's fields by field type using the `FieldType` enum. Pass the filtered fields into the `possibleValues` array parameter of the custom property * If the prompt includes specific named fields, check that if these fields exist in the current table by comparing to the `name` property of the values in the `table.fields` array. If any of the named fields do exist, pass their `Field` objects into the `defaultValue` parameter of the custom property * ONLY show instructions to configure custom properties in the Interface Extension's UI when those custom properties do not have values set for the current page * Here is an example of how to use custom properties to avoid hard-coding fields: ``` import {useCustomProperties} from '@airtable/blocks/interface/ui'; import {FieldType} from '@airtable/blocks/interface/models'; function getCustomProperties(base: Base) { // For single-table extensions, you can use base.tables[0] in the custom properties setup function const table = base.tables[0]; const isNumberField = (field: {id: FieldId, config: FieldConfig}) => field.config.type === FieldType.NUMBER; const numberFields = table.fields.filter(isNumberField); const defaultXAxis = numberFields[0]; const defaultYAxis = numberFields[1]; return [ { key: 'title', label: 'Title', type: 'string', defaultValue: 'Chart', }, { key: 'xAxis', label: 'X-axis', type: 'field', table, shouldFieldBeAllowed: isNumberField, defaultValue: defaultXAxis, }, { key: 'yAxis', label: 'Y-axis', type: 'field', table, shouldFieldBeAllowed: isNumberField, defaultValue: defaultYAxis, }, { key: 'color', label: 'Color', type: 'enum', possibleValues: [ {value: 'red', label: 'Red'}, {value: 'blue', label: 'Blue'}, {value: 'green', label: 'Green'}, ], defaultValue: 'red', }, { key: 'showLegend', label: 'Show Legend', type: 'boolean', defaultValue: true, }, ]; } function MyApp() { const {customPropertyValueByKey, errorState} = useCustomProperties(getCustomProperties); } ``` * Here is an example of how to use table custom properties for multi-table Custom Elements: ``` import {useCustomProperties} from '@airtable/blocks/interface/ui'; import {FieldType} from '@airtable/blocks/interface/models'; function getCustomProperties(base: Base) { return [ { key: 'projectsTable', label: 'Projects Table', type: 'table', defaultValue: base.tables.find((table) => table.name.toLowerCase().includes('projects')), }, { key: 'tasksTable', label: 'Tasks Table', type: 'table', defaultValue: base.tables.find((table) => table.name.toLowerCase().includes('tasks')), }, ]; } function MyApp() { const {customPropertyValueByKey, errorState} = useCustomProperties(getCustomProperties); const projectsTable = customPropertyValueByKey.projectsTable as Table; const tasksTable = customPropertyValueByKey.tasksTable as Table; } ``` * Here is an example of how to use custom properties to avoid hard-coding credentials: ``` import {useCustomProperties} from '@airtable/blocks/interface/ui'; function getCustomProperties(base: Base) { return [ { key: 'apiKey', label: 'API Key', type: 'string', defaultValue: '', }, ]; } function MyApp() { const {customPropertyValueByKey, errorState} = useCustomProperties(getCustomProperties); } ``` * If any custom properties are not set for the current page, render instructions to configure them via the "properties panel" of the Interface Extension * Interface Extensions can be used to integrate with third-party systems (e.g. sources of data or tools) that require credentials (like API keys, usernames or passwords) to authenticate with * ALWAYS use to allow builders to configure credentials rather than storing them in the code of your Interface Extension * Inform the user that you have used custom properties to store any credentials when responding to the prompt * Airtable Interfaces provide Record Detail pages, which allow users to see much more detail about a specific record, edit data, run Automations relating to that record and more. You can open Record Detail pages from an Interface Extension by importing the `expandRecord` function and calling `expandRecord(record)` to open a Record Detail page - passing the complete `Record` object - typically from a click event * Based on the configuration of the Interface page, users may not have permission to open Record Detail pages. Call `table.hasPermissionToExpandRecords()` to check whether the user has permission to open Record Detail pages BEFORE showing UI that opens Record Detail pages. * Opening Record Detail pages directly is the preferred approach to show more detail about a specific record rather than using popovers or custom detail panes, unless specifically instructed * Unless specifically instructed to use a different library, prefer the following npm packages (and import the libraries however their documentation recommends): * recharts: for rendering charts and data visualizations * @google/model-viewer: for rendering 3D models * mapbox-gl: for rendering maps when instructed to use Mapbox ONLY. Make sure to also import 'mapbox-gl/dist/mapbox-gl.css' * marked: for parsing Markdown * @phosphor-icons/react: for icons * @dnd-kit/core: for drag & drop interactions * Make sure to install third-party libraries first (don't depend on that being done for you) * If a third-party library doesn't list React 19 as a peer dependency, use the `--legacy-peer-deps` flag when installing npm packages. * Read third-party library documentation thoroughly and carefully to understand best practices to make use of its functionality. Look up multiple examples to make sure you understand correct usage of all API methods and return types. DO NOT invent or create API methods in third-party libraries * Unless specifically instructed otherwise, use Tailwind for styling (no import needed). * Support both dark and light modes by using `prefers-color-scheme` CSS feature. * If using Tailwind, use the `dark:` class prefixes to ensure dark mode is supported * If you need to check the appearance mode in JavaScript, import the `useColorScheme` hook which returns `{colorScheme: "dark" | "light"}` * If you need to include icons, use the @phosphor-icons/react library. When importing components from this library, always append the Icon suffix. For example, instead of importing `ArrowRight`, import `ArrowRightIcon`. * Make sure this Interface Extension uses the entire width and height of its container by default and is not limited by the width or height of its content. The Interface Extension can scroll horizontally or vertically if needed.