From 560306ba20040e1835df13c46599e37d3b146fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Tue, 27 Jan 2026 01:26:47 +0100 Subject: [PATCH 1/9] feat: grist add-on first commit --- forms-bridge/addons/grist/README.md | 65 ++++++ forms-bridge/addons/grist/assets/logo.png | 1 + .../addons/grist/class-grist-addon.php | 192 ++++++++++++++++++ .../addons/grist/class-grist-form-bridge.php | 191 +++++++++++++++++ .../grist/templates/grist-integration.php | 25 +++ 5 files changed, 474 insertions(+) create mode 100644 forms-bridge/addons/grist/README.md create mode 100644 forms-bridge/addons/grist/assets/logo.png create mode 100644 forms-bridge/addons/grist/class-grist-addon.php create mode 100644 forms-bridge/addons/grist/class-grist-form-bridge.php create mode 100644 forms-bridge/addons/grist/templates/grist-integration.php diff --git a/forms-bridge/addons/grist/README.md b/forms-bridge/addons/grist/README.md new file mode 100644 index 00000000..1a42e5a4 --- /dev/null +++ b/forms-bridge/addons/grist/README.md @@ -0,0 +1,65 @@ +# Grist Addon for Forms Bridge + +This addon integrates Forms Bridge with Grist using its REST API. + +## Features + +- Connect WordPress forms to Grist databases +- Map form fields to Grist table columns +- Support for all Grist column types (Text, Numeric, Bool, Date, DateTime, Choice, Ref, RefList) +- Automatic type mapping between WordPress and Grist + +## Requirements + +- Forms Bridge plugin installed and activated +- Grist account with API access +- Grist API token + +## Setup + +1. **Install the addon**: Place the `grist` folder in `forms-bridge/addons/` +2. **Enable the addon**: Go to Forms Bridge settings and enable the Grist addon +3. **Configure backend**: + - Go to Forms Bridge → Backends + - Add a new backend with your Grist API URL (e.g., `https://docs.getgrist.com`) + - Add your Grist API token as the access token +4. **Create a bridge**: + - Go to Forms Bridge → Bridges + - Add a new bridge with the Grist addon + - Select your form and the Grist backend + - Set the endpoint to your Grist table API (e.g., `/api/tables/{tableId}/records`) + - Map your form fields to Grist columns + +## API Endpoints + +Common Grist API endpoints: +- `/api/docs` - API documentation +- `/api/tables` - List tables +- `/api/tables/{tableId}` - Get table info +- `/api/tables/{tableId}/records` - Get/add records +- `/api/tables/{tableId}/schema` - Get table schema + +## Field Mapping + +Grist column types are automatically mapped to appropriate form field types: + +| Grist Type | Form Type | +|------------|-----------| +| Text | text | +| Numeric | number | +| Bool | boolean | +| Date | string | +| DateTime | string | +| Choice | string | +| Ref | string | +| RefList | array | + +## Troubleshooting + +- **Connection issues**: Verify your Grist API URL and token are correct +- **Field mapping errors**: Check that your form fields match the Grist column types +- **Permission errors**: Ensure your API token has write access to the target table + +## Support + +For support, please open an issue on the Forms Bridge GitHub repository. diff --git a/forms-bridge/addons/grist/assets/logo.png b/forms-bridge/addons/grist/assets/logo.png new file mode 100644 index 00000000..303587de --- /dev/null +++ b/forms-bridge/addons/grist/assets/logo.png @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZQ8AAAAASUVORK5CYII= diff --git a/forms-bridge/addons/grist/class-grist-addon.php b/forms-bridge/addons/grist/class-grist-addon.php new file mode 100644 index 00000000..ff942377 --- /dev/null +++ b/forms-bridge/addons/grist/class-grist-addon.php @@ -0,0 +1,192 @@ + '__grist-' . time(), + 'backend' => $backend, + 'endpoint' => '/api/docs', + 'method' => 'GET', + ) + ); + + $backend = $bridge->backend; + if ( ! $backend ) { + Logger::log( 'Grist backend ping error: Bridge has no valid backend', Logger::ERROR ); + return false; + } + + $credential = $backend->credential; + if ( ! $credential ) { + Logger::log( 'Grist backend ping error: Backend has no valid credential', Logger::ERROR ); + return false; + } + + $access_token = $credential->get_access_token(); + + if ( ! $access_token ) { + Logger::log( 'Grist backend ping error: Unable to recover the credential access token', Logger::ERROR ); + return false; + } + + return true; + } + + /** + * Performs a GET request against the backend endpoint and retrive the response data. + * + * @param string $endpoint Grist endpoint. + * @param string $backend Backend name. + * + * @return array|WP_Error + */ + public function fetch( $endpoint, $backend ) { + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend' ); + } + + $credential = $backend->credential; + if ( ! $credential ) { + return new WP_Error( 'invalid_credential' ); + } + + $access_token = $credential->get_access_token(); + if ( ! $access_token ) { + return new WP_Error( 'invalid_credential' ); + } + + $response = http_bridge_get( + $backend->base_url . $endpoint, + array(), + array( + 'Authorization' => "Bearer {$access_token}", + 'Accept' => 'application/json', + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $response; + } + + /** + * Performs an introspection of the backend API and returns a list of available endpoints. + * + * @param string $backend Target backend name. + * @param string|null $method HTTP method. + * + * @return array|WP_Error + */ + public function get_endpoints( $backend, $method = null ) { + // Grist doesn't have a standard endpoint discovery API + // Return common Grist API endpoints + return array( + '/api/docs', + '/api/tables', + '/api/records', + ); + } + + /** + * Performs an introspection of the backend endpoint and returns API fields + * and accepted content type. + * + * @param string $endpoint Grist endpoint. + * @param string $backend Backend name. + * @param string|null $method HTTP method. + * + * @return array List of fields and content type of the endpoint. + */ + public function get_endpoint_schema( $endpoint, $backend, $method = null ) { + if ( 'POST' !== $method ) { + return array(); + } + + $bridge = null; + $bridges = FBAPI::get_addon_bridges( self::NAME ); + foreach ( $bridges as $candidate ) { + $data = $candidate->data(); + if ( ! $data ) { + continue; + } + + if ( + $data['endpoint'] === $endpoint && + $data['backend'] === $backend + ) { + /** + * Current bridge. + * + * @var Grist_Form_Bridge + */ + $bridge = $candidate; + } + } + + if ( ! isset( $bridge ) ) { + return array(); + } + + $fields = $bridge->get_fields(); + + if ( is_wp_error( $fields ) ) { + return array(); + } + + $schema = array(); + foreach ( $fields as $field ) { + $schema[] = array( + 'name' => $field['name'], + 'schema' => array( 'type' => $field['type'] ), + ); + } + + return $schema; + } +} + +Grist_Addon::setup(); diff --git a/forms-bridge/addons/grist/class-grist-form-bridge.php b/forms-bridge/addons/grist/class-grist-form-bridge.php new file mode 100644 index 00000000..1f47a150 --- /dev/null +++ b/forms-bridge/addons/grist/class-grist-form-bridge.php @@ -0,0 +1,191 @@ +|WP_Error + */ + public function get_fields( $backend = null ) { + if ( ! $this->is_valid ) { + return new WP_Error( 'invalid_bridge' ); + } + + if ( ! $backend ) { + $backend = $this->backend; + } + + // For Grist, we need to fetch the table schema + // The endpoint should be something like /api/tables/{tableId}/schema + $response = $backend->get( $this->endpoint ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + // Parse Grist schema response + // Grist typically returns columns in a specific format + if ( empty( $response['data']['columns'] ) ) { + return array(); + } + + $fields = array(); + foreach ( $response['data']['columns'] as $column ) { + $fields[] = array( + 'name' => $column['name'], + 'type' => $this->map_grist_type( $column['type'] ), + ); + } + + return $fields; + } + + /** + * Maps Grist column types to standard types. + * + * @param string $grist_type Grist column type. + * + * @return string + */ + private function map_grist_type( $grist_type ) { + $type_mapping = array( + 'Text' => 'string', + 'Numeric' => 'number', + 'Bool' => 'boolean', + 'Date' => 'string', + 'DateTime' => 'string', + 'Choice' => 'string', + 'Ref' => 'string', + 'RefList' => 'array', + ); + + return $type_mapping[ $grist_type ] ?? 'string'; + } + + /** + * Sends the payload to the backend. + * + * @param array $payload Submission data. + * @param array $attachments Submission's attached files. Will be ignored. + * + * @return array|WP_Error Http request response. + */ + public function submit( $payload = array(), $attachments = array() ) { + if ( ! $this->is_valid ) { + return new WP_Error( + 'invalid_bridge', + 'Bridge data is invalid', + (array) $this->data, + ); + } + + $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend not found' ); + } + + $fields = $this->get_fields( $backend ); + if ( is_wp_error( $fields ) ) { + return $fields; + } + + $payload = self::flatten_payload( $payload ); + + $records = array(); + foreach ( $fields as $field ) { + $field_name = $field['name']; + if ( isset( $payload[ $field_name ] ) ) { + $records[ $field_name ] = $payload[ $field_name ]; + } + } + + $endpoint = $this->endpoint; + $method = $this->method; + + if ( 'POST' === $method ) { + $payload = array( + 'records' => array( $records ), + ); + } + + return $this->backend->$method( $endpoint, $payload ); + } + + /** + * Flattens nested arrays in the payload and concatenates their keys as field names. + * + * @param array $payload Submission payload. + * @param string $path Prefix to prepend to the field name. + * + * @return array Flattened payload. + */ + private static function flatten_payload( $payload, $path = '' ) { + $flat = array(); + foreach ( $payload as $field => $value ) { + $key = $path . $field; + $value = self::flatten_value( $value, $key ); + + if ( ! is_array( $value ) ) { + $flat[ $key ] = $value; + } else { + foreach ( $value as $_key => $_val ) { + $flat[ $_key ] = $_val; + } + } + } + + return $flat; + } + + /** + * Returns array values as a flat vector of field key values. + * + * @param mixed $value Payload value. + * @param string $path Hierarchical path to the value. + * + * @return mixed + */ + private static function flatten_value( $value, $path = '' ) { + if ( ! is_array( $value ) ) { + return $value; + } + + if ( wp_is_numeric_array( $value ) ) { + $simple_items = array_filter( $value, fn( $item ) => ! is_array( $item ) ); + + if ( count( $simple_items ) === count( $value ) ) { + return implode( ',', $value ); + } + } + + return self::flatten_payload( $value, $path . '.' ); + } +} diff --git a/forms-bridge/addons/grist/templates/grist-integration.php b/forms-bridge/addons/grist/templates/grist-integration.php new file mode 100644 index 00000000..0423726a --- /dev/null +++ b/forms-bridge/addons/grist/templates/grist-integration.php @@ -0,0 +1,25 @@ + __( 'Grist Integration', 'forms-bridge' ), + 'description' => __( 'Template for integrating with Grist via REST API', 'forms-bridge' ), + 'icon' => 'grist', + 'data' => array( + 'name' => 'grist-integration', + 'form_id' => '', + 'backend' => '', + 'endpoint' => '/api/records', + 'method' => 'POST', + 'custom_fields' => array(), + 'mutations' => array( + array(), + ), + 'workflow' => array(), + 'enabled' => true, + ), +); From 5c0089036d6686c9db7e0c5af4afe90728d3e554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 18:04:02 +0100 Subject: [PATCH 2/9] feat: grist logo --- forms-bridge/addons/grist/README.md | 65 ------------------ forms-bridge/addons/grist/assets/logo.png | Bin 93 -> 12532 bytes .../addons/grist/class-grist-addon.php | 8 +++ .../addons/grist/class-grist-form-bridge.php | 3 +- .../grist/templates/grist-integration.php | 25 ------- 5 files changed, 9 insertions(+), 92 deletions(-) delete mode 100644 forms-bridge/addons/grist/README.md delete mode 100644 forms-bridge/addons/grist/templates/grist-integration.php diff --git a/forms-bridge/addons/grist/README.md b/forms-bridge/addons/grist/README.md deleted file mode 100644 index 1a42e5a4..00000000 --- a/forms-bridge/addons/grist/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Grist Addon for Forms Bridge - -This addon integrates Forms Bridge with Grist using its REST API. - -## Features - -- Connect WordPress forms to Grist databases -- Map form fields to Grist table columns -- Support for all Grist column types (Text, Numeric, Bool, Date, DateTime, Choice, Ref, RefList) -- Automatic type mapping between WordPress and Grist - -## Requirements - -- Forms Bridge plugin installed and activated -- Grist account with API access -- Grist API token - -## Setup - -1. **Install the addon**: Place the `grist` folder in `forms-bridge/addons/` -2. **Enable the addon**: Go to Forms Bridge settings and enable the Grist addon -3. **Configure backend**: - - Go to Forms Bridge → Backends - - Add a new backend with your Grist API URL (e.g., `https://docs.getgrist.com`) - - Add your Grist API token as the access token -4. **Create a bridge**: - - Go to Forms Bridge → Bridges - - Add a new bridge with the Grist addon - - Select your form and the Grist backend - - Set the endpoint to your Grist table API (e.g., `/api/tables/{tableId}/records`) - - Map your form fields to Grist columns - -## API Endpoints - -Common Grist API endpoints: -- `/api/docs` - API documentation -- `/api/tables` - List tables -- `/api/tables/{tableId}` - Get table info -- `/api/tables/{tableId}/records` - Get/add records -- `/api/tables/{tableId}/schema` - Get table schema - -## Field Mapping - -Grist column types are automatically mapped to appropriate form field types: - -| Grist Type | Form Type | -|------------|-----------| -| Text | text | -| Numeric | number | -| Bool | boolean | -| Date | string | -| DateTime | string | -| Choice | string | -| Ref | string | -| RefList | array | - -## Troubleshooting - -- **Connection issues**: Verify your Grist API URL and token are correct -- **Field mapping errors**: Check that your form fields match the Grist column types -- **Permission errors**: Ensure your API token has write access to the target table - -## Support - -For support, please open an issue on the Forms Bridge GitHub repository. diff --git a/forms-bridge/addons/grist/assets/logo.png b/forms-bridge/addons/grist/assets/logo.png index 303587de3a680a43cdad245f072bbb2330eae58e..8c69a95c9d524c7c44c44e1de709182757eb9c78 100644 GIT binary patch literal 12532 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 literal 93 zcmc}{bMg-=H}G~(&vyg@zu?de*U)qj(-lNGIXeadDQ80u$8u+ofU`l4YhYNAiBWEV jg?@#Fqhq71`Haw diff --git a/forms-bridge/addons/grist/class-grist-addon.php b/forms-bridge/addons/grist/class-grist-addon.php index ff942377..1f38d2d7 100644 --- a/forms-bridge/addons/grist/class-grist-addon.php +++ b/forms-bridge/addons/grist/class-grist-addon.php @@ -1,7 +1,15 @@ __( 'Grist Integration', 'forms-bridge' ), - 'description' => __( 'Template for integrating with Grist via REST API', 'forms-bridge' ), - 'icon' => 'grist', - 'data' => array( - 'name' => 'grist-integration', - 'form_id' => '', - 'backend' => '', - 'endpoint' => '/api/records', - 'method' => 'POST', - 'custom_fields' => array(), - 'mutations' => array( - array(), - ), - 'workflow' => array(), - 'enabled' => true, - ), -); From 2146583caf4b50a8e711f1eea398ceeba66016a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 18:19:43 +0100 Subject: [PATCH 3/9] feat: update readmes --- README.md | 3 ++- forms-bridge/readme.txt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc162717..3aaf0142 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,10 @@ Forms Bridge has the following add-ons: **🗓️ Productivity** -- [Airtable](https://formsbridge.codeccoop.org/documentation/airtable) +- [Airtable](https://formsbridge.codeccoop.org/documentation/airtable/) - [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) - [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) +- [Grist](https://formsbridge.codeccoop.org/documentation/grist/) - [Nextcloud](https://formsbridge.codeccoop.org/documentation/nextcloud/) **📨 Messaging & Collaboration** diff --git a/forms-bridge/readme.txt b/forms-bridge/readme.txt index 7426185e..6aace13f 100644 --- a/forms-bridge/readme.txt +++ b/forms-bridge/readme.txt @@ -70,8 +70,10 @@ Forms Bridge has the following add-ons: **🗓️ Productivity** +* [Airtable](https://formsbridge.codeccoop.org/documentation/airtable/) * [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) * [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) +* [Grist](https://formsbridge.codeccoop.org/documentation/grist/) * [Nextcloud](https://formsbridge.codeccoop.org/documentation/nextcloud/) **📨 Messaging & Collaboration** From 1d5b784d96ab2bc710ac1ff65ba9eb0180517367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 18:26:29 +0100 Subject: [PATCH 4/9] fix: wpcf7 empty uploads warnings --- forms-bridge/includes/class-forms-bridge.php | 2 +- forms-bridge/integrations/wpcf7/class-wpcf7-integration.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/forms-bridge/includes/class-forms-bridge.php b/forms-bridge/includes/class-forms-bridge.php index ff670892..2030dd1c 100644 --- a/forms-bridge/includes/class-forms-bridge.php +++ b/forms-bridge/includes/class-forms-bridge.php @@ -491,7 +491,7 @@ private static function prune_empties( $submission_data ) { * * @return array Map of uploaded files. */ - public static function attachments( $uploads ) { + public static function attachments( $uploads = array() ) { $attachments = array(); foreach ( $uploads as $name => $upload ) { diff --git a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php index a9ea3487..4039de00 100644 --- a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php +++ b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php @@ -441,6 +441,8 @@ protected function submission_uploads( $submission ) { 'path' => $is_multi ? $paths : $paths[0], 'is_multi' => $is_multi, ); + } else { + unset( $uploads[ $file_name ] ); } } From 095280ceaedaa6967db4fb968e7564d614d53df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 18:33:13 +0100 Subject: [PATCH 5/9] feat: update readmes --- forms-bridge/readme.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/forms-bridge/readme.txt b/forms-bridge/readme.txt index 6aace13f..ef768474 100644 --- a/forms-bridge/readme.txt +++ b/forms-bridge/readme.txt @@ -95,7 +95,7 @@ Think of a bridge as a pipeline through which your form submissions data flows t Think of it as a "connection profile" for your API. Save your API URL, headers, and credentials once, then reuse them across all your bridges. No need to re-enter details every time! **Credentials** -Authenticate your HTTP requests using API keys, Basic Auth, OAuth, RPC credentials and Digest Auth. +Authenticate your HTTP requests using API keys, Basic Auth, Bearer Tokens, OAuth, RPC credentials and Digest Auth. **Custom fields & mappers** Add hidden data (like user IDs or product codes) and rename/transform fields to match your API’s requirements. @@ -139,7 +139,7 @@ Maybe a little understanding about how HTTP and HTTP-like APIs works will be req = What if my API requires authentication? = -Forms Bridge supports API keys, Basic Auth, OAuth, RPC credentials, Digest Auth and custom headers. +Forms Bridge supports API keys, Basic Auth, Bearer Tokens, OAuth, RPC credentials, Digest Auth and custom headers. = Is there a free trial? = @@ -165,9 +165,12 @@ You can get support from Còdec using the [Forms Bridge support forum](https://w = 4.3.1 = feat: airtable add-on +feat: grist add-on feat: dynamic form field templates for google sheets, airtable and nextcloud feat: nextcloud get endpoints method +feat: rename bearer to oauth and new support for bearer tokens fix: skip internal field attributes in wpcf7 form creation + = 4.3.0 = * feat: form's bridge chain order From d8673878b509321bd1fe8d200a8daf4752b865d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Fri, 30 Jan 2026 15:20:24 +0100 Subject: [PATCH 6/9] feat: grist addon --- .../addons/grist/class-grist-addon.php | 152 ++++++---- .../addons/grist/class-grist-form-bridge.php | 274 +++++++++++------- forms-bridge/addons/grist/hooks.php | 183 ++++++++++++ .../addons/grist/templates/from-table.php | 12 + 4 files changed, 459 insertions(+), 162 deletions(-) create mode 100644 forms-bridge/addons/grist/hooks.php create mode 100644 forms-bridge/addons/grist/templates/from-table.php diff --git a/forms-bridge/addons/grist/class-grist-addon.php b/forms-bridge/addons/grist/class-grist-addon.php index 1f38d2d7..83d5d1cf 100644 --- a/forms-bridge/addons/grist/class-grist-addon.php +++ b/forms-bridge/addons/grist/class-grist-addon.php @@ -15,6 +15,7 @@ } require_once 'class-grist-form-bridge.php'; +require_once 'hooks.php'; /** * Grist addon class. @@ -50,31 +51,17 @@ class Grist_Addon extends Addon { * @return boolean */ public function ping( $backend ) { - $bridge = new Grist_Form_Bridge( - array( - 'name' => '__grist-' . time(), - 'backend' => $backend, - 'endpoint' => '/api/docs', - 'method' => 'GET', - ) - ); + $backend = FBAPI::get_backend( $backend ); - $backend = $bridge->backend; if ( ! $backend ) { - Logger::log( 'Grist backend ping error: Bridge has no valid backend', Logger::ERROR ); + Logger::log( 'Grist backend ping error: Backend is unkown or invalid', Logger::ERROR ); return false; } - $credential = $backend->credential; - if ( ! $credential ) { - Logger::log( 'Grist backend ping error: Backend has no valid credential', Logger::ERROR ); - return false; - } + $response = $backend->get( '/api/orgs' ); - $access_token = $credential->get_access_token(); - - if ( ! $access_token ) { - Logger::log( 'Grist backend ping error: Unable to recover the credential access token', Logger::ERROR ); + if ( is_wp_error( $response ) ) { + Logger::log( 'Grist backend ping error: Unable to list grist organizations', Logger::ERROR ); return false; } @@ -92,33 +79,59 @@ public function ping( $backend ) { public function fetch( $endpoint, $backend ) { $backend = FBAPI::get_backend( $backend ); if ( ! $backend ) { - return new WP_Error( 'invalid_backend' ); + return new WP_Error( 'invalid_backend', 'Backend is unkown or invalid', array( 'backend' => $backend ) ); } - $credential = $backend->credential; - if ( ! $credential ) { - return new WP_Error( 'invalid_credential' ); + if ( $endpoint && '/api/orgs/{orgId}/tables' !== $endpoint ) { + return $backend->get( $endpoint ); } - $access_token = $credential->get_access_token(); - if ( ! $access_token ) { - return new WP_Error( 'invalid_credential' ); + if ( preg_match( '/[^\/]+(?=\.getgrist.com)/', $backend->base_url, $matches ) ) { + $org_id = $matches[0]; } - $response = http_bridge_get( - $backend->base_url . $endpoint, - array(), - array( - 'Authorization' => "Bearer {$access_token}", - 'Accept' => 'application/json', - ) - ); + if ( ! isset( $org_id ) ) { + foreach ( $backend->headers as $header => $value ) { + if ( 'orgid' === strtolower( $header ) ) { + $org_id = $value; + break; + } + } + } + + if ( ! isset( $org_id ) ) { + return new WP_Error( 'invalid_backend', 'Backend does not have the orgId header', $backend->data() ); + } + + $response = $backend->get( "/api/orgs/{$org_id}/workspaces" ); if ( is_wp_error( $response ) ) { return $response; } - return $response; + $tables = array(); + foreach ( $response['data'] as $workspace ) { + foreach ( $workspace['docs'] as $doc ) { + $docs_response = $backend->get( "/api/docs/{$doc['id']}/tables" ); + + if ( is_wp_error( $docs_response ) ) { + continue; + } + + foreach ( $docs_response['data']['tables'] as $table ) { + $tables[] = array( + 'org_id' => $org_id, + 'doc_id' => $doc['urlId'], + 'doc_name' => $doc['name'], + 'id' => $table['id'], + 'label' => "{$doc['name']}/{$table['id']}", + 'endpoint' => "/api/docs/{$doc['urlId']}/tables/{$table['id']}/records", + ); + } + } + } + + return array( 'data' => array( 'tables' => $tables ) ); } /** @@ -130,13 +143,18 @@ public function fetch( $endpoint, $backend ) { * @return array|WP_Error */ public function get_endpoints( $backend, $method = null ) { - // Grist doesn't have a standard endpoint discovery API - // Return common Grist API endpoints - return array( - '/api/docs', - '/api/tables', - '/api/records', - ); + $response = $this->fetch( null, $backend ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $endpoints = array(); + foreach ( $response['data']['tables'] as $table ) { + $endpoints[] = $table['endpoint']; + } + + return $endpoints; } /** @@ -154,28 +172,16 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { return array(); } - $bridge = null; - $bridges = FBAPI::get_addon_bridges( self::NAME ); - foreach ( $bridges as $candidate ) { - $data = $candidate->data(); - if ( ! $data ) { - continue; - } - - if ( - $data['endpoint'] === $endpoint && - $data['backend'] === $backend - ) { - /** - * Current bridge. - * - * @var Grist_Form_Bridge - */ - $bridge = $candidate; - } - } + $bridge = new Grist_Form_Bridge( + array( + 'name' => '__grist-endpoint-introspection', + 'backend' => $backend, + 'endpoint' => $endpoint, + 'method' => 'GET', + ) + ); - if ( ! isset( $bridge ) ) { + if ( ! $bridge->is_valid ) { return array(); } @@ -187,9 +193,27 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { $schema = array(); foreach ( $fields as $field ) { + switch ( $field['type'] ) { + case 'number': + $type = 'number'; + break; + case 'checkbox': + $type = 'boolean'; + break; + case 'select': + $type = $field['is_multi'] ? 'array' : 'string'; + break; + case 'file': + $type = 'file'; + break; + default: + $type = 'string'; + break; + } + $schema[] = array( 'name' => $field['name'], - 'schema' => array( 'type' => $field['type'] ), + 'schema' => array( 'type' => $type ), ); } diff --git a/forms-bridge/addons/grist/class-grist-form-bridge.php b/forms-bridge/addons/grist/class-grist-form-bridge.php index b5f6fa88..2edd2acf 100644 --- a/forms-bridge/addons/grist/class-grist-form-bridge.php +++ b/forms-bridge/addons/grist/class-grist-form-bridge.php @@ -7,6 +7,7 @@ namespace FORMS_BRIDGE; +use FBAPI; use WP_Error; if ( ! defined( 'ABSPATH' ) ) { @@ -28,67 +29,122 @@ public function __construct( $data ) { } /** - * Fetches the fields of the Grist table and returns them as an array. + * Gets the document id from the bridge endpoint. * - * @param Backend|null $backend Bridge backend instance. + * @return string|null + */ + private function doc_id() { + preg_match( '/(?<=docs\/)[^\/]+/', $this->endpoint, $matches ); + + if ( empty( $matches[0] ) ) { + return null; + } + + return $matches[0]; + } + + /** + * Gets the table id from the bridge endpoint. + * + * @return string|null + */ + private function table_id() { + preg_match( '/(?<=tables\/)[^\/]+/', $this->endpoint, $matches ); + + if ( empty( $matches[0] ) ) { + return null; + } + + return $matches[0]; + } + + /** + * Fetches the fields of the Grist table and returns them as an array. * * @return array|WP_Error */ - public function get_fields( $backend = null ) { + public function get_fields() { if ( ! $this->is_valid ) { - return new WP_Error( 'invalid_bridge' ); + return new WP_Error( 'invalid_bridge', 'The bridge is invalid', $this->data ); } + $backend = $this->backend; if ( ! $backend ) { - $backend = $this->backend; + return new WP_Error( 'invalid_backend', 'The bridge backend is unkown or invalid', $this->data ); } - // For Grist, we need to fetch the table schema - // The endpoint should be something like /api/tables/{tableId}/schema - $response = $backend->get( $this->endpoint ); + $doc_id = $this->doc_id(); + $table_id = $this->table_id(); - if ( is_wp_error( $response ) ) { - return $response; + if ( ! $doc_id || ! $table_id ) { + return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); } - // Parse Grist schema response, Grist typically returns columns in a specific format. - if ( empty( $response['data']['columns'] ) ) { - return array(); + $endpoint = "/api/docs/{$doc_id}/tables/{$table_id}/columns"; + $response = $backend->get( $endpoint ); + + if ( is_wp_error( $response ) ) { + return $response; } $fields = array(); foreach ( $response['data']['columns'] as $column ) { - $fields[] = array( - 'name' => $column['name'], - 'type' => $this->map_grist_type( $column['type'] ), + if ( + $column['fields']['isFormula'] + || $column['fields']['formula'] + || 0 === strpos( $column['fields']['type'], 'Ref:' ) + || 0 === strpos( $column['fields']['type'], 'RefList:' ) + ) { + continue; + } + + $field = array( + 'name' => $column['id'], + 'label' => $column['fields']['label'], ); + + switch ( $column['fields']['type'] ) { + case 'Attachments': + $field['type'] = 'file'; + break; + case 'Choice': + case 'ChoiceList': + $field['type'] = 'select'; + $field['is_multi'] = 'ChoiceList' === $column['fields']['type']; + + $options = json_decode( $column['fields']['widgetOptions'], true ) ?: array( 'choices' => array() ); + $field['options'] = array_map( + function ( $choice ) { + return array( + 'value' => $choice, + 'label' => $choice, + ); + }, + $options['choices'], + ); + + break; + case 'Bool': + $field['type'] = 'checkbox'; + break; + case 'Int': + case 'Numeric': + $field['type'] = 'number'; + break; + case 'Date': + $field['type'] = 'date'; + break; + default: + $field['type'] = 'text'; + break; + } + + $fields[] = $field; } return $fields; } - /** - * Maps Grist column types to standard types. - * - * @param string $grist_type Grist column type. - * - * @return string - */ - private function map_grist_type( $grist_type ) { - $type_mapping = array( - 'Text' => 'string', - 'Numeric' => 'number', - 'Bool' => 'boolean', - 'Date' => 'string', - 'DateTime' => 'string', - 'Choice' => 'string', - 'Ref' => 'string', - 'RefList' => 'array', - ); - - return $type_mapping[ $grist_type ] ?? 'string'; - } - /** * Sends the payload to the backend. * @@ -111,80 +167,102 @@ public function submit( $payload = array(), $attachments = array() ) { return new WP_Error( 'invalid_backend', 'Backend not found' ); } - $fields = $this->get_fields( $backend ); - if ( is_wp_error( $fields ) ) { - return $fields; - } - - $payload = self::flatten_payload( $payload ); - - $records = array(); - foreach ( $fields as $field ) { - $field_name = $field['name']; - if ( isset( $payload[ $field_name ] ) ) { - $records[ $field_name ] = $payload[ $field_name ]; - } - } - $endpoint = $this->endpoint; $method = $this->method; if ( 'POST' === $method ) { - $payload = array( - 'records' => array( $records ), - ); - } + $fields = $this->get_fields( $backend ); + if ( is_wp_error( $fields ) ) { + return $fields; + } - return $this->backend->$method( $endpoint, $payload ); - } + $data_fields = array(); + $attachments = array(); - /** - * Flattens nested arrays in the payload and concatenates their keys as field names. - * - * @param array $payload Submission payload. - * @param string $path Prefix to prepend to the field name. - * - * @return array Flattened payload. - */ - private static function flatten_payload( $payload, $path = '' ) { - $flat = array(); - foreach ( $payload as $field => $value ) { - $key = $path . $field; - $value = self::flatten_value( $value, $key ); - - if ( ! is_array( $value ) ) { - $flat[ $key ] = $value; - } else { - foreach ( $value as $_key => $_val ) { - $flat[ $_key ] = $_val; + $l = count( $fields ); + for ( $i = 0; $i < $l; ++$i ) { + if ( 'file' === $fields[ $i ]['type'] ) { + $attachment_field = $fields[ $i ]; + $attachment_name = $attachment_field['name']; + + $names = array_keys( $payload ); + $keys = array_filter( + $names, + function ( $name ) use ( $attachment_name ) { + $name = preg_replace( '/_\d+$/', '', $name ); + return $name === $attachment_name; + } + ); + + foreach ( $keys as $key ) { + $attachments[] = array( + 'file' => $payload[ $attachment_name ], + 'name' => $attachment_name, + 'key' => $key, + ); + + unset( $payload[ $key ] ); + unset( $payload[ $key . '_filename' ] ); + } + } else { + $data_fields[] = $fields[ $i ]; } } - } - return $flat; - } + $record = array( 'fields' => array() ); - /** - * Returns array values as a flat vector of field key values. - * - * @param mixed $value Payload value. - * @param string $path Hierarchical path to the value. - * - * @return mixed - */ - private static function flatten_value( $value, $path = '' ) { - if ( ! is_array( $value ) ) { - return $value; - } + if ( count( $attachments ) ) { + $doc_id = $this->doc_id(); - if ( wp_is_numeric_array( $value ) ) { - $simple_items = array_filter( $value, fn( $item ) => ! is_array( $item ) ); + $uploads = Forms_Bridge::attachments( FBAPI::get_uploads() ); - if ( count( $simple_items ) === count( $value ) ) { - return implode( ',', $value ); + foreach ( $attachments as $attachment ) { + foreach ( $uploads as $upload_name => $path ) { + if ( $upload_name === $attachment['key'] || sanitize_title( $attachment['key'] ) === $upload_name ) { + $attachment_path = $path; + break; + } + } + + if ( ! isset( $attachment_path ) || ! is_file( $attachment_path ) ) { + continue; + } + + $upload_response = $backend->post( + "/api/docs/{$doc_id}/attachments", + array(), + array( 'Content-Type' => 'multipart/form-data' ), + array( 'upload' => $attachment_path ), + ); + + if ( is_wp_error( $upload_response ) ) { + return $upload_response; + } + + $record['fields'][ $attachment['name'] ] = $upload_response['data'][0]; + } } + + foreach ( $data_fields as $field ) { + $field_name = $field['name']; + if ( isset( $payload[ $field_name ] ) ) { + if ( 'select' === $field['type'] && ( $field['is_multi'] ?? false ) ) { + if ( ! is_array( $payload[ $field_name ] ) ) { + $payload[ $field_name ] = array( $payload[ $field_name ] ); + } + + array_unshift( $payload[ $field_name ], 'L' ); + } + + $record['fields'][ $field_name ] = $payload[ $field_name ]; + } + } + + $payload = array( + 'records' => array( $record ), + ); } - return self::flatten_payload( $value, $path . '.' ); + return $this->backend->$method( $endpoint, $payload ); } } diff --git a/forms-bridge/addons/grist/hooks.php b/forms-bridge/addons/grist/hooks.php new file mode 100644 index 00000000..3f9b218f --- /dev/null +++ b/forms-bridge/addons/grist/hooks.php @@ -0,0 +1,183 @@ + array( + array( + 'ref' => '#credential', + 'name' => 'name', + 'label' => __( 'Name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'schema', + 'type' => 'text', + 'value' => 'Bearer', + ), + array( + 'ref' => '#credential', + 'name' => 'access_token', + 'label' => __( 'Access token', 'forms-bridge' ), + 'description' => __( + 'Register your Personal Access Token in your Grist account settings page', + 'forms-bridge' + ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'expires_at', + 'type' => 'number', + 'value' => time() + 60 * 60 * 24 * 365 * 100, + ), + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'label' => __( 'Table', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + 'options' => array( + 'endpoint' => '/api/orgs/{orgId}/tables', + 'finger' => array( + 'value' => 'tables[].endpoint', + 'label' => 'tables[].label', + ), + ), + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + array( + 'ref' => '#backend', + 'name' => 'name', + 'default' => 'Grist API', + ), + array( + 'ref' => '#backend', + 'name' => 'base_url', + 'default' => 'https://docs.getgrist.com', + ), + array( + 'ref' => '#backend/headers[]', + 'name' => 'orgId', + 'label' => __( 'Team ID', 'forms-bridge' ), + 'description' => __( + 'Use `docs` by default for personal sites. If you\'ve created team site, it should be the team subdomain (e.g. `example` from https://example.getgrist.com). In self-hosted instances, the team ID is the last part of the team\'s homepage URL (e.g. `example` from http://localhost:8484/o/example)', + 'forms-bridge', + ), + 'type' => 'text', + 'required' => true, + 'default' => 'docs', + ), + ), + 'backend' => array(), + 'bridge' => array( + 'backend' => 'Grist API', + 'endpoint' => '', + ), + 'credential' => array( + 'name' => '', + 'schema' => 'Bearer', + 'access_token' => '', + 'expires_at' => 0, + ), + ), + $defaults, + $schema + ); + + return $defaults; + }, + 10, + 3 +); + +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( 0 !== strpos( $template_id, 'grist-' ) ) { + return $data; + } + + if ( empty( $data['form']['fields'] ) ) { + $credential_data = $data['credential']; + $credential_data['name'] = '__grist-' . time(); + + Credential::temp_registration( $credential_data ); + + $backend_data = $data['backend']; + $backend_data['credential'] = $credential_data['name']; + $backend_data['name'] = '__grist-' . time(); + + Backend::temp_registration( $backend_data ); + + $bridge_data = $data['bridge']; + $bridge_data['name'] = '__grist-' . time(); + $bridge_data['backend'] = $backend_data['name']; + + $bridge = new Grist_Form_Bridge( $bridge_data ); + + $fields = $bridge->get_fields(); + if ( ! is_wp_error( $fields ) ) { + foreach ( $fields as $field ) { + $field_name = $field['name']; + $sanitized = sanitize_title( $field_name ); + if ( strtolower( $field_name ) !== $sanitized ) { + $field['name'] = $sanitized; + } + + $data['form']['fields'][] = $field; + + if ( $field['name'] !== $field_name ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + if ( 'file' === $field['type'] ) { + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field['name'], + 'cast' => 'null', + ); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'], + 'to' => $field['name'], + 'cast' => 'inherit', + ); + } + } + } + } + + return $data; + }, + 10, + 2, +); diff --git a/forms-bridge/addons/grist/templates/from-table.php b/forms-bridge/addons/grist/templates/from-table.php new file mode 100644 index 00000000..62e65cce --- /dev/null +++ b/forms-bridge/addons/grist/templates/from-table.php @@ -0,0 +1,12 @@ + __( 'From table', 'forms-bridge' ), + 'description' => __( 'Create a bridge and a form based on an existing table', 'forms-bridge' ), + 'form' => array( 'fields' => array() ), +); From bac66d193021813ea697adab9cadea0ecf84bb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Fri, 30 Jan 2026 15:53:54 +0100 Subject: [PATCH 7/9] feat: standarize airtable fields serialization --- .../addons/airtable/class-airtable-addon.php | 72 ++++------- .../airtable/class-airtable-form-bridge.php | 101 +++++++++++++-- forms-bridge/addons/airtable/hooks.php | 122 +++++------------- forms-bridge/addons/grist/hooks.php | 10 ++ 4 files changed, 151 insertions(+), 154 deletions(-) diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index 844943b9..74d1370a 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -51,18 +51,16 @@ class Airtable_Addon extends Addon { * @return boolean */ public function ping( $backend ) { - $bridge = new Airtable_Form_Bridge( - array( - 'name' => '__airtable-' . time(), - 'backend' => $backend, - 'endpoint' => '/v0/meta/bases', - 'method' => 'GET', - ) - ); + $backend = FBAPI::get_backend( $backend ); - $response = $bridge->submit(); + if ( ! $backend ) { + Logger::log( 'Airtable backend ping error: Backend is unkown or invalid', Logger::ERROR ); + return false; + } + + $response = $backend->get( '/v0/meta/bases' ); if ( is_wp_error( $response ) ) { - Logger::log( 'Airtable backend ping error: Unable to recover the credential access token', Logger::ERROR ); + Logger::log( 'Airtable backend ping error: Unable to list airtable bases', Logger::ERROR ); return false; } @@ -78,16 +76,16 @@ public function ping( $backend ) { * @return array|WP_Error */ public function fetch( $endpoint, $backend ) { - $bridge = new Airtable_Form_Bridge( - array( - 'name' => '__airtable-meta-bases', - 'backend' => $backend, - 'endpoint' => '/v0/meta/bases', - 'method' => 'GET', - ), - ); + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend is unkown or invalid', array( 'backend' => $backend ) ); + } - $response = $bridge->submit(); + if ( $endpoint && '/v0/meta/tables' !== $endpoint ) { + return $backend->get( $endpoint ); + } + + $response = $backend->get( '/v0/meta/bases' ); if ( is_wp_error( $response ) ) { return $response; @@ -95,8 +93,7 @@ public function fetch( $endpoint, $backend ) { $tables = array(); foreach ( $response['data']['bases'] as $base ) { - $schema_response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) - ->submit(); + $schema_response = $backend->get( "/v0/meta/bases/{$base['id']}/tables" ); if ( is_wp_error( $schema_response ) ) { return $schema_response; @@ -172,45 +169,20 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { $schema = array(); foreach ( $fields as $field ) { - if ( - in_array( - $field['type'], - array( - 'aiText', - 'formula', - 'autoNumber', - 'button', - 'count', - 'createdBy', - 'createdTime', - 'lastModifiedBy', - 'lastModifiedTime', - 'rollup', - 'externalSyncSource', - 'multipleCollaborators', - 'multipleLookupValues', - 'multipleRecordLinks', - ), - true, - ) - ) { - continue; - } - switch ( $field['type'] ) { - case 'rating': case 'number': $type = 'number'; break; case 'checkbox': $type = 'boolean'; break; - case 'multipleSelects': - $type = 'array'; + case 'select': + $type = $field['is_multi'] ? 'array' : 'string'; break; - case 'multipleAttachments': + case 'file': $type = 'file'; break; + case 'textarea': default: $type = 'string'; break; diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 7faa0b48..29da08da 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -68,19 +68,20 @@ public function get_fields() { return new WP_Error( 'invalid_bridge', 'The bridge is invalid', $this->data ); } + $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'The bridge backend is unkown or invalid', $this->data ); + } + $base_id = $this->base_id(); $table_id = $this->table_id(); if ( ! $base_id || ! $table_id ) { - return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); + return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); } - $response = $this->patch( - array( - 'method' => 'GET', - 'endpoint' => "/v0/meta/bases/{$base_id}/tables", - ) - )->submit(); + $endpoint = "/v0/meta/bases/{$base_id}/tables"; + $response = $backend->get( $endpoint ); if ( is_wp_error( $response ) ) { return $response; @@ -97,7 +98,81 @@ public function get_fields() { return new WP_Error( 'not_found', 'Table not found', $this->data ); } - return $table['fields']; + $fields = array(); + foreach ( $table['fields'] as $air_field ) { + if ( + in_array( + $air_field['type'], + array( + 'aiText', + 'formula', + 'autoNumber', + 'button', + 'count', + 'createdBy', + 'createdTime', + 'lastModifiedBy', + 'lastModifiedTime', + 'rollup', + 'externalSyncSource', + 'multipleCollaborators', + 'multipleLookupValues', + 'multipleRecordLinks', + ), + true, + ) + ) { + continue; + } + + $field = array( + 'id' => $air_field['id'], + 'name' => $air_field['name'], + 'label' => $air_field['name'], + ); + + switch ( $air_field['type'] ) { + case 'multipleAttachments': + $field['type'] = 'file'; + $field['is_multi'] = true; + break; + case 'rating': + case 'number': + $field['type'] = 'number'; + break; + case 'checkbox': + $field['type'] = 'checkbox'; + break; + case 'multipleSelects': + case 'singleSelect': + $field['type'] = 'select'; + $field['options'] = array_map( + function ( $choice ) { + return array( + 'value' => $choice['name'], + 'label' => $choice['name'], + ); + }, + $air_field['options']['choices'], + ); + + $field['is_multi'] = 'multipleSelects' === $air_field['type']; + break; + case 'date': + $field['type'] = 'date'; + break; + case 'multilineText': + $field['type'] = 'textarea'; + break; + default: + $field['type'] = 'text'; + break; + } + + $fields[] = $field; + } + + return $fields; } /** @@ -136,7 +211,7 @@ public function submit( $payload = array(), $attachments = array() ) { $l = count( $fields ); for ( $i = 0; $i < $l; ++$i ) { - if ( 'multipleAttachments' === $fields[ $i ]['type'] ) { + if ( 'file' === $fields[ $i ]['type'] ) { $attachment_field = $fields[ $i ]; $attachment_name = $attachment_field['name']; @@ -170,7 +245,11 @@ function ( $name ) use ( $attachment_name ) { $field_name = $data_field['name']; if ( isset( $payload[ $field_name ] ) ) { - if ( 'multipleSelects' === $data_field['type'] && ! is_array( $payload[ $field_name ] ) ) { + if ( + 'select' === $data_field['type'] + && $data_field['is_multi'] + && ! is_array( $payload[ $field_name ] ) + ) { $payload[ $field_name ] = array( $payload[ $field_name ] ); } @@ -204,7 +283,7 @@ function ( $name ) use ( $attachment_name ) { $filename = basename( $path ); $filetype = wp_check_filetype( $path ); if ( empty( $filetype['type'] ) ) { - $filetype['type'] = mime_content_type( $path ); + $filetype['type'] = mime_content_type( $path ) ?: 'octet/stream'; } } } diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index a60cc27a..5436086a 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -47,6 +47,12 @@ function ( $defaults, $addon, $schema ) { 'type' => 'text', 'required' => true, ), + array( + 'ref' => '#credential', + 'name' => 'expires_at', + 'type' => 'number', + 'value' => time() + 60 * 60 * 24 * 365 * 100, + ), array( 'ref' => '#bridge', 'name' => 'endpoint', @@ -54,7 +60,7 @@ function ( $defaults, $addon, $schema ) { 'type' => 'text', 'required' => true, 'options' => array( - 'endpoint' => '/v0/meta/bases', + 'endpoint' => '/v0/meta/tables', 'finger' => array( 'value' => 'tables[].endpoint', 'label' => 'tables[].label', @@ -105,7 +111,7 @@ function ( $defaults, $addon, $schema ) { add_filter( 'forms_bridge_template_data', function ( $data, $template_id ) { - if ( strpos( $template_id, 'airtable-' ) !== 0 ) { + if ( 0 !== strpos( $template_id, 'airtable-' ) ) { return $data; } @@ -130,95 +136,42 @@ function ( $data, $template_id ) { $fields = $bridge->get_fields(); if ( ! is_wp_error( $fields ) ) { foreach ( $fields as $field ) { - if ( - in_array( - $field['type'], - array( - 'aiText', - 'formula', - 'autoNumber', - 'button', - 'count', - 'createdBy', - 'createdTime', - 'lastModifiedBy', - 'lastModifiedTime', - 'rollup', - 'externalSyncSource', - 'multipleCollaborators', - 'multipleLookupValues', - 'multipleRecordLinks', - ), - true, - ) - ) { - continue; - } - - $field_name = sanitize_title( $field['name'] ); - $form_field = array( - 'name' => $field_name, - 'label' => $field['name'], - ); - - switch ( $field['type'] ) { - case 'multipleAttachments': - $form_field['type'] = 'file'; - $form_field['is_multi'] = true; - break; - case 'rating': - case 'number': - $form_field['type'] = 'number'; - break; - case 'checkbox': - $form_field['type'] = 'checkbox'; - break; - case 'multipleSelects': - case 'singleSelect': - $form_field['type'] = 'select'; - $form_field['options'] = array_map( - function ( $choice ) { - return array( - 'value' => $choice['name'], - 'label' => $choice['name'], - ); - }, - $field['options']['choices'], - ); - - $form_field['is_multi'] = 'multipleSelects' === $field['type']; - break; - case 'date': - $form_field['type'] = 'date'; - break; - case 'multilineText': - $form_field['type'] = 'textarea'; - break; - default: - $form_field['type'] = 'text'; - break; + $field_name = $field['name']; + $sanitized = sanitize_title( $field_name ); + if ( strtolower( $field_name ) !== $sanitized ) { + $field['name'] = $sanitized; } - $data['form']['fields'][] = $form_field; + $data['form']['fields'][] = $field; - if ( $field['name'] !== $form_field['name'] ) { + if ( $field['label'] !== $field['name'] ) { if ( ! isset( $data['bridge']['mutations'][0] ) ) { $data['bridge']['mutations'][0] = array(); } - if ( 'file' === $form_field['type'] ) { + if ( 'file' === $field['type'] ) { $data['bridge']['mutations'][0][] = array( - 'from' => $form_field['name'] . '_filename', - 'to' => $field['name'], + 'from' => $field['name'] . '_filename', + 'to' => $field_name . '_filename', 'cast' => 'null', ); } $data['bridge']['mutations'][0][] = array( - 'from' => $form_field['name'], - 'to' => $field['name'], + 'from' => $field['name'], + 'to' => $field_name, 'cast' => 'inherit', ); + } elseif ( 'file' === $field['type'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field_name . '_filename', + 'cast' => 'null', + ); } } } @@ -229,20 +182,3 @@ function ( $choice ) { 10, 2, ); - -add_filter( - 'http_bridge_oauth_url', - function ( $url, $verb ) { - if ( false === strstr( $url, 'airtable.com' ) ) { - return $url; - } - - if ( 'auth' === $verb ) { - $url .= 'orize'; - } - - return $url; - }, - 10, - 2 -); diff --git a/forms-bridge/addons/grist/hooks.php b/forms-bridge/addons/grist/hooks.php index 3f9b218f..d2ea5deb 100644 --- a/forms-bridge/addons/grist/hooks.php +++ b/forms-bridge/addons/grist/hooks.php @@ -171,6 +171,16 @@ function ( $data, $template_id ) { 'to' => $field['name'], 'cast' => 'inherit', ); + } elseif ( 'file' === $field['type'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field['name'], + 'cast' => 'null', + ); } } } From 605ce2ef2fb18759510b9ce622e32e3847207add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Fri, 30 Jan 2026 17:24:58 +0100 Subject: [PATCH 8/9] feat: grist test case --- tests/addons/test-grist.php | 687 ++++++++++++++++++++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 tests/addons/test-grist.php diff --git a/tests/addons/test-grist.php b/tests/addons/test-grist.php new file mode 100644 index 00000000..0afc7908 --- /dev/null +++ b/tests/addons/test-grist.php @@ -0,0 +1,687 @@ + self::CREDENTIAL_NAME, + 'schema' => 'Bearer', + 'access_token' => 'test-api-key', + 'expires_at' => time() + 3600, + ) + ), + ); + } + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => self::BACKEND_NAME, + 'base_url' => self::BACKEND_URL, + 'credential' => self::CREDENTIAL_NAME, + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'orgId', + 'value' => 'test', + ), + ), + ) + ), + ); + } + + /** + * HTTP requests interceptor. + * + * @param mixed $pre Initial pre hook value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public static function pre_http_request( $pre, $args, $url ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + $http = array( + 'code' => 200, + 'message' => 'OK', + ); + + $method = $args['method'] ?? 'GET'; + + // Parse URL to determine the endpoint being called. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + + // Parse the body to determine the method being called. + $body = array(); + if ( ! empty( $args['body'] ) ) { + if ( is_string( $args['body'] ) ) { + $body = json_decode( $args['body'], true ); + } else { + $body = $args['body']; + } + } + + // Return appropriate mock response based on endpoint. + if ( self::$mock_response ) { + $http = self::$mock_response['http'] ?? $http; + unset( self::$mock_response['http'] ); + $response_body = self::$mock_response; + + self::$mock_response = null; + } else { + $response_body = self::get_mock_response( $method, $path, $body ); + } + + return array( + 'response' => $http, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( $response_body ), + 'http_response' => null, + ); + } + + /** + * Get mock response based on API endpoint. + * + * @param string $method HTTP method. + * @param string $path API endpoint path. + * @param array $body Request body. + * + * @return array Mock response. + */ + private static function get_mock_response( $method, $path, $body ) { + // Organizations endpoint. + if ( '/api/orgs' === $path && 'GET' === $method ) { + return array( + 'orgs' => array( + array( + 'id' => 'org123456789', + 'name' => 'Test Organization', + ), + ), + ); + } + + // Workspaces endpoint. + if ( preg_match( '/^\/api\/orgs\/([^\/]+)\/workspaces$/', $path ) && 'GET' === $method ) { + return array( + array( + 'id' => 'ws123456789', + 'name' => 'Test Workspace', + 'docs' => array( + array( + 'id' => self::DOC_ID, + 'urlId' => self::DOC_ID, + 'name' => 'Test Document', + 'access' => 'owners', + ), + ), + ), + ); + } + + // Tables endpoint. + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables$/', $path ) && 'GET' === $method ) { + return array( + 'tables' => array( + array( + 'id' => self::TABLE_ID, + 'name' => 'Test Table', + ), + array( + 'id' => 'another-table', + 'name' => 'Another Table', + ), + ), + ); + } + + // Columns endpoint. + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables\/([^\/]+)\/columns$/', $path ) && 'GET' === $method ) { + return array( + 'columns' => array( + array( + 'id' => 'name', + 'fields' => array( + 'label' => 'Name', + 'type' => 'Text', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'email', + 'fields' => array( + 'label' => 'Email', + 'type' => 'Text', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'age', + 'fields' => array( + 'label' => 'Age', + 'type' => 'Int', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'active', + 'fields' => array( + 'label' => 'Active', + 'type' => 'Bool', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'tags', + 'fields' => array( + 'label' => 'Tags', + 'type' => 'ChoiceList', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => json_encode( + array( + 'choices' => array( 'A', 'B', 'C' ), + ) + ), + ), + ), + array( + 'id' => 'attachment', + 'fields' => array( + 'label' => 'Attachment', + 'type' => 'Attachments', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'formula_field', + 'fields' => array( + 'label' => 'Formula Field', + 'type' => 'Text', + 'isFormula' => true, + 'formula' => '=1+1', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'ref_field', + 'fields' => array( + 'label' => 'Reference Field', + 'type' => 'Ref:test-table-456', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + ), + ); + } + + // Records endpoint (POST). + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables\/([^\/]+)\/records$/', $path ) ) { + if ( 'POST' === $method ) { + return array( + 'records' => array( array( 'id' => 'rec123456789' ) ), + ); + } else { + return array( + 'records' => array( + array( + 'id' => 'rec123456789', + 'fields' => array( + 'email' => 'john.doe@example.coop', + 'name' => 'John Doe', + 'age' => 43, + 'tags' => array( 'L', 'A', 'B' ), + 'active' => true, + 'attachments' => array( 'L', 1 ), + ), + ), + ), + ); + } + } + + // Attachments endpoint (POST). + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/attachments$/', $path ) && 'POST' === $method ) { + return array( 1 ); + } + + // Default empty response. + return array(); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + tests_add_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + tests_add_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + tests_add_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down test filters. + */ + public function tear_down() { + remove_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + remove_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + remove_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + + parent::tear_down(); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Grist_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables', + 'method' => 'GET', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertFalse( $bridge->is_valid ); + } + + /** + * Test POST request to create a record. + */ + public function test_post_create_record() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'tags' => array( 'A', 'B' ), + 'active' => true, + 'age' => 42, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + } + + /** + * Test POST request to create a record. + */ + public function test_post_create_record_with_upload() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'attachment' => 'file', + 'tags' => array( 'A', 'B' ), + 'active' => true, + 'age' => 42, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + } + + /** + * Test GET request to fetch table schema. + */ + public function test_get_table_schema() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit(); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'records', $response['data'] ); + $this->assertCount( 1, $response['data']['records'] ); + $this->assertArrayHasKey( 'fields', $response['data']['records'][0] ); + $this->assertCount( 6, $response['data']['records'][0]['fields'] ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'grist' ); + $response = $addon->ping( self::BACKEND_NAME ); + + $this->assertTrue( $response ); + } + + /** + * Test addon fetch method to get tables. + */ + public function test_addon_fetch_tables() { + $addon = Addon::addon( 'grist' ); + $response = $addon->fetch( '/api/orgs/{orgId}/tables', self::BACKEND_NAME ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'tables', $response['data'] ); + $this->assertCount( 2, $response['data']['tables'] ); + + // Check that tables have the expected structure. + $table = $response['data']['tables'][0]; + $this->assertArrayHasKey( 'org_id', $table ); + $this->assertArrayHasKey( 'doc_id', $table ); + $this->assertArrayHasKey( 'doc_name', $table ); + $this->assertArrayHasKey( 'label', $table ); + $this->assertArrayHasKey( 'id', $table ); + $this->assertArrayHasKey( 'endpoint', $table ); + $this->assertEquals( 'Test Document/' . self::TABLE_ID, $table['label'] ); + $this->assertEquals( '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', $table['endpoint'] ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'grist' ); + $endpoints = $addon->get_endpoints( self::BACKEND_NAME ); + + $this->assertIsArray( $endpoints ); + $this->assertCount( 2, $endpoints ); + $this->assertContains( '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', $endpoints ); + } + + /** + * Test addon get_endpoint_schema method for POST. + */ + public function test_addon_get_endpoint_schema_post() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'POST' + ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + // Check that schema contains expected fields. + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertContains( 'email', $field_names ); + $this->assertContains( 'active', $field_names ); + $this->assertContains( 'age', $field_names ); + $this->assertContains( 'tags', $field_names ); + $this->assertContains( 'attachment', $field_names ); + + // Check field types. + $schema_map = array(); + foreach ( $schema as $field ) { + $schema_map[ $field['name'] ] = $field['schema']['type']; + } + + $this->assertEquals( 'string', $schema_map['name'] ); + $this->assertEquals( 'string', $schema_map['email'] ); + $this->assertEquals( 'boolean', $schema_map['active'] ); + $this->assertEquals( 'array', $schema_map['tags'] ); + $this->assertEquals( 'file', $schema_map['attachment'] ); + $this->assertEquals( 'number', $schema_map['age'] ); + } + + /** + * Test addon get_endpoint_schema method for non-POST methods. + */ + public function test_addon_get_endpoint_schema_non_post() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'GET' + ); + + // Should return empty array for non-POST methods. + $this->assertIsArray( $schema ); + $this->assertEmpty( $schema ); + } + + /** + * Test error response handling. + */ + public function test_error_response_handling() { + self::$mock_response = array( + 'http' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'error' => 'Bad request: invalid API key', + ); + + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test authorization header transformation. + */ + public function test_authorization_header_transformation() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $bridge->submit(); + + // Check that the request was made. + $this->assertNotNull( self::$request ); + + // Verify the Authorization header was transformed. + $headers = self::$request['args']['headers'] ?? array(); + $this->assertArrayHasKey( 'Authorization', $headers ); + $this->assertStringContainsString( 'Bearer', $headers['Authorization'] ); + $this->assertStringContainsString( 'test-api-key', $headers['Authorization'] ); + } + + /** + * Test field filtering in schema - should exclude certain field types. + */ + public function test_field_filtering_in_schema() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'POST' + ); + + // Should only include the singleLineText field, not the aiText field. + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertNotContains( 'formula_field', $field_names ); + $this->assertNotContains( 'ref_field', $field_names ); + $this->assertCount( 6, $schema ); + } +} From 7ee66625c07e60513634b74cfb4a1f638ce73fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Sat, 31 Jan 2026 12:33:45 +0100 Subject: [PATCH 9/9] feat: test airtable uploads --- .../airtable/class-airtable-form-bridge.php | 4 +- tests/addons/test-airtable.php | 178 ++++++++++++------ tests/addons/test-grist.php | 2 +- 3 files changed, 128 insertions(+), 56 deletions(-) diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 29da08da..4afd2311 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -296,10 +296,10 @@ function ( $name ) use ( $attachment_name ) { )->post( "/v0/{$base_id}/{$record_id}/{$attachment['id']}/uploadAttachment", array( - 'contentType' => $filetype['type'], + 'contentType' => $filetype['type'] ?? 'octet/stream', 'file' => $attachment['file'], 'filename' => $filename, - ) + ), ); if ( is_wp_error( $upload_response ) ) { diff --git a/tests/addons/test-airtable.php b/tests/addons/test-airtable.php index 125c328c..6a77b6ec 100644 --- a/tests/addons/test-airtable.php +++ b/tests/addons/test-airtable.php @@ -184,7 +184,7 @@ private static function get_mock_response( $method, $path, $body ) { ), ), ); - } elseif ( preg_match( '#/v0/meta/bases/(app\d+)/tables#', $path, $matches ) ) { + } elseif ( preg_match( '#^/v0/meta/bases/(app\d+)/tables$#', $path, $matches ) ) { // Tables for a specific base. return array( 'tables' => array( @@ -213,15 +213,36 @@ private static function get_mock_response( $method, $path, $body ) { 'type' => 'number', ), array( - 'id' => 'fld888888888', - 'name' => 'Tags', - 'type' => 'multipleSelects', + 'id' => 'fld888888888', + 'name' => 'Tags', + 'type' => 'multipleSelects', + 'options' => array( + 'choices' => array( + array( + 'name' => 'A', + 'id' => 't1', + ), + array( + 'name' => 'B', + 'id' => 't2', + ), + array( + 'name' => 'C', + 'id' => 't3', + ), + ), + ), ), array( 'id' => 'fld999999999', 'name' => 'Summary', 'type' => 'aiText', ), + array( + 'id' => 'fld2222222222', + 'name' => 'Profile Picture', + 'type' => 'multipleAttachments', + ), ), ), array( @@ -239,45 +260,73 @@ private static function get_mock_response( $method, $path, $body ) { ), ); } - } elseif ( preg_match( '#/v0/(app\d+)/([^/]+)#', $path, $matches ) ) { + } elseif ( preg_match( '#^/v0/(app\d+)/([^/]+)$#', $path, $matches ) ) { // Table data endpoint. if ( 'GET' === $method ) { // Mock field schema for GET requests. return array( - 'fields' => array( + 'records' => array( array( - 'id' => 'fld123456789', - 'name' => 'Name', - 'type' => 'singleLineText', - ), - array( - 'id' => 'fld987654321', - 'name' => 'Email', - 'type' => 'email', - ), - array( - 'id' => 'fld555555555', - 'name' => 'Active', - 'type' => 'checkbox', - ), - array( - 'id' => 'fld777777777', - 'name' => 'Score', - 'type' => 'number', - ), - array( - 'id' => 'fld888888888', - 'name' => 'Tags', - 'type' => 'multipleSelects', + 'id' => 'rec123456789', + 'createdTime' => '2023-01-01T00:00:00.000Z', + 'fields' => array( + array( + 'id' => 'fld123456789', + 'name' => 'Name', + 'type' => 'singleLineText', + ), + array( + 'id' => 'fld987654321', + 'name' => 'Email', + 'type' => 'email', + ), + array( + 'id' => 'fld555555555', + 'name' => 'Active', + 'type' => 'checkbox', + ), + array( + 'id' => 'fld777777777', + 'name' => 'Score', + 'type' => 'number', + ), + array( + 'id' => 'fld888888888', + 'name' => 'Tags', + 'type' => 'multipleSelects', + ), + ), ), ), ); } elseif ( 'POST' === $method ) { // Mock successful record creation for POST requests. return array( - 'id' => 'rec123456789', - 'fields' => $body['records'][0]['fields'], - 'createdTime' => '2023-01-01T00:00:00.000Z', + 'records' => array( + array( + 'id' => 'rec123456789', + 'fields' => $body['records'][0]['fields'], + 'createdTime' => '2023-01-01T00:00:00.000Z', + ), + ), + ); + } + } elseif ( preg_match( '#^/v0/(app\d+)/([^/]+)/uploadAttachment$#', $path, $matches ) ) { + if ( $method === 'POST' ) { + return array( + 'createdTime' => '2022-02-01T21:25:05.663Z', + 'fields' => array( + 'fld00000000000000' => array( + array( + 'filename' => 'sample.txt', + 'id' => 'att00000000000000', + 'size' => 11, + 'type' => 'text/plain', + 'url' => 'https://v5.airtableusercontent.com/v3/u/29/29/1716940800000/ffhiecnieIwxisnIBDSAln/foDeknw_G5CdkdPW1j-U0yUCX9YSaE1EJft3wvXb85pnTY1sKZdYeFvKpsM-fqOa6Bnu5MQVPA_ApINEUXL_E3SAZn6z01VN9Pn9SluhSy4NoakZGapcvl4tuN3jktO2Dt7Ck_gh4oMdsrcV8J-t_A/53m17XmDDHsNtIqzM1PQVnRKutK6damFgNNS5WCaTbI', + ), + ), + ), + 'id' => 'rec00000000000000', ); } } @@ -372,16 +421,49 @@ public function test_post_create_record() { ); $payload = array( - 'Name' => 'John Doe', - 'Email' => 'john.doe@example.com', + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com', + 'Score' => 99, + 'Tags' => array( 'A', 'B' ), + 'Active' => true, ); $response = $bridge->submit( $payload ); $this->assertFalse( is_wp_error( $response ) ); $this->assertArrayHasKey( 'data', $response ); - $this->assertEquals( 'rec123456789', $response['data']['id'] ); - $this->assertEquals( 'John Doe', $response['data']['fields']['Name'] ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + $this->assertEquals( 'John Doe', $response['data']['records'][0]['fields']['Name'] ); + } + + /** + * Test POST request to create a record with uploads. + */ + public function test_post_create_record_with_uploads() { + $bridge = new Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + 'method' => 'POST', + ) + ); + + $payload = array( + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com', + 'Score' => 99, + 'Tags' => array( 'A', 'B' ), + 'Active' => true, + 'Profile Picture' => 'file', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + $this->assertEquals( 'John Doe', $response['data']['records'][0]['fields']['Name'] ); } /** @@ -401,8 +483,10 @@ public function test_get_table_schema() { $this->assertFalse( is_wp_error( $response ) ); $this->assertArrayHasKey( 'data', $response ); - $this->assertArrayHasKey( 'fields', $response['data'] ); - $this->assertCount( 5, $response['data']['fields'] ); + $this->assertArrayHasKey( 'records', $response['data'] ); + $this->assertCount( 1, $response['data']['records'] ); + $this->assertArrayHasKey( 'fields', $response['data']['records'][0] ); + $this->assertCount( 5, $response['data']['records'][0]['fields'] ); } /** @@ -474,6 +558,7 @@ public function test_addon_get_endpoint_schema_post() { $this->assertContains( 'Active', $field_names ); $this->assertContains( 'Score', $field_names ); $this->assertContains( 'Tags', $field_names ); + $this->assertContains( 'Profile Picture', $field_names ); // Check field types. $schema_map = array(); @@ -486,6 +571,7 @@ public function test_addon_get_endpoint_schema_post() { $this->assertEquals( 'boolean', $schema_map['Active'] ); $this->assertEquals( 'number', $schema_map['Score'] ); $this->assertEquals( 'array', $schema_map['Tags'] ); + $this->assertEquals( 'file', $schema_map['Profile Picture'] ); } /** @@ -581,20 +667,6 @@ public function test_authorization_header_transformation() { * Test field filtering in schema - should exclude certain field types. */ public function test_field_filtering_in_schema() { - // Add a field that should be excluded. - $mock_fields = array( - array( - 'id' => 'fld_excluded', - 'name' => 'ExcludedField', - 'type' => 'aiText', // This type should be excluded. - ), - array( - 'id' => 'fld_included', - 'name' => 'IncludedField', - 'type' => 'singleLineText', // This type should be included. - ), - ); - $addon = Addon::addon( 'airtable' ); $schema = $addon->get_endpoint_schema( '/v0/app123456789/Contacts', @@ -606,6 +678,6 @@ public function test_field_filtering_in_schema() { $field_names = array_column( $schema, 'name' ); $this->assertContains( 'Name', $field_names ); $this->assertNotContains( 'Summary', $field_names ); - $this->assertCount( 5, $schema ); + $this->assertCount( 6, $schema ); } } diff --git a/tests/addons/test-grist.php b/tests/addons/test-grist.php index 0afc7908..7b76832b 100644 --- a/tests/addons/test-grist.php +++ b/tests/addons/test-grist.php @@ -446,7 +446,7 @@ public function test_post_create_record() { } /** - * Test POST request to create a record. + * Test POST request to create a record with uploads. */ public function test_post_create_record_with_upload() { $bridge = new Grist_Form_Bridge(