From bf45655739016a8addf5cadba72bda3acdf1b2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 02:54:09 +0100 Subject: [PATCH 01/17] feat: bearer credentials and pkce oauth flow --- forms-bridge/addons/gcalendar/hooks.php | 4 +-- forms-bridge/addons/gsheets/hooks.php | 4 +-- forms-bridge/addons/slack/hooks.php | 4 +-- forms-bridge/addons/zoho/hooks.php | 11 +++----- forms-bridge/deps/http | 2 +- src/components/Backend/Authentication.jsx | 3 ++- src/components/Credential/AuthorizeButton.jsx | 23 ++++++----------- .../Wizard/useAuthorizedCredential.js | 25 +++++++------------ src/lib/utils.js | 8 ------ tests/addons/test-bigin.php | 2 +- tests/addons/test-gcalendar.php | 2 +- tests/addons/test-gsheets.php | 2 +- tests/addons/test-zoho.php | 2 +- 13 files changed, 33 insertions(+), 59 deletions(-) diff --git a/forms-bridge/addons/gcalendar/hooks.php b/forms-bridge/addons/gcalendar/hooks.php index 55bdf1e1..237932a5 100644 --- a/forms-bridge/addons/gcalendar/hooks.php +++ b/forms-bridge/addons/gcalendar/hooks.php @@ -50,7 +50,7 @@ function ( $defaults, $addon, $schema ) { 'ref' => '#credential', 'name' => 'schema', 'type' => 'text', - 'value' => 'Bearer', + 'value' => 'OAuth', ), array( 'ref' => '#credential', @@ -159,7 +159,7 @@ function ( $defaults, $addon, $schema ) { ), 'credential' => array( 'name' => '', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', 'scope' => 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events', 'client_id' => '', diff --git a/forms-bridge/addons/gsheets/hooks.php b/forms-bridge/addons/gsheets/hooks.php index 1bc263d5..4bd3c509 100644 --- a/forms-bridge/addons/gsheets/hooks.php +++ b/forms-bridge/addons/gsheets/hooks.php @@ -59,7 +59,7 @@ function ( $defaults, $addon, $schema ) { 'ref' => '#credential', 'name' => 'schema', 'type' => 'text', - 'value' => 'Bearer', + 'value' => 'OAuth', ), array( 'ref' => '#credential', @@ -144,7 +144,7 @@ function ( $defaults, $addon, $schema ) { ), 'credential' => array( 'name' => '', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', 'scope' => 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets', 'client_id' => '', diff --git a/forms-bridge/addons/slack/hooks.php b/forms-bridge/addons/slack/hooks.php index 6f6ff215..c683e523 100644 --- a/forms-bridge/addons/slack/hooks.php +++ b/forms-bridge/addons/slack/hooks.php @@ -30,7 +30,7 @@ function ( $defaults, $addon, $schema ) { 'ref' => '#credential', 'name' => 'schema', 'type' => 'text', - 'value' => 'Bearer', + 'value' => 'OAuth', ), array( 'ref' => '#credential', @@ -281,7 +281,7 @@ function ( $defaults, $addon, $schema ) { ), 'credential' => array( 'name' => '', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://slack.com/oauth/v2', 'scope' => 'chat:write,channels:read,users:read', 'client_id' => '', diff --git a/forms-bridge/addons/zoho/hooks.php b/forms-bridge/addons/zoho/hooks.php index a2303bb9..8d46d4cb 100644 --- a/forms-bridge/addons/zoho/hooks.php +++ b/forms-bridge/addons/zoho/hooks.php @@ -30,7 +30,7 @@ function ( $defaults, $addon, $schema ) { 'ref' => '#credential', 'name' => 'schema', 'type' => 'text', - 'value' => 'Bearer', + 'value' => 'OAuth', ), array( 'ref' => '#credential', @@ -172,7 +172,7 @@ function ( $defaults, $addon, $schema ) { ), 'credential' => array( 'name' => '', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://accounts.{region}/oauth/v2', 'scope' => 'ZohoCRM.modules.ALL,ZohoCRM.settings.modules.READ,ZohoCRM.settings.layouts.READ,ZohoCRM.users.READ', 'client_id' => '', @@ -182,13 +182,8 @@ function ( $defaults, $addon, $schema ) { 'refresh_token' => '', ), 'backend' => array( + 'name' => 'Zoho API', 'base_url' => 'https://www.zohoapis.{region}', - 'headers' => array( - array( - 'name' => 'Accept', - 'value' => 'application/json', - ), - ), ), ), $defaults, diff --git a/forms-bridge/deps/http b/forms-bridge/deps/http index 225bbf91..28a82700 160000 --- a/forms-bridge/deps/http +++ b/forms-bridge/deps/http @@ -1 +1 @@ -Subproject commit 225bbf917cd8df2713398f7081f1f43afd7bafd0 +Subproject commit 28a82700bb840f4d9eafd3f93dc4785da4bac68c diff --git a/src/components/Backend/Authentication.jsx b/src/components/Backend/Authentication.jsx index 6c1a82cf..bc5d1c2c 100644 --- a/src/components/Backend/Authentication.jsx +++ b/src/components/Backend/Authentication.jsx @@ -8,6 +8,7 @@ const OPTIONS = [ { label: "Basic", value: "Basic" }, { label: "Token", value: "Token" }, { label: "Bearer", value: "Bearer" }, + { label: "OAuth", value: "OAuth" }, ]; export default function BackendAuthentication({ data = {}, setData }) { @@ -23,7 +24,7 @@ export default function BackendAuthentication({ data = {}, setData }) { __nextHasNoMarginBottom /> - {data.schema && data.schema !== "Bearer" && ( + {data.schema && data.schema !== "OAuth" && ( { + .then(({ success, data }) => { if (!success) throw "error"; + const { url, params } = data; const form = document.createElement("form"); - form.action = redirect_url; + form.action = url; form.method = "GET"; form.target = "_blank"; - let innerHTML = ` - - - - - -`; - - if (data.scope) { - innerHTML += ``; - } - - form.innerHTML = innerHTML; + form.innerHTML = Object.keys(params).reduce((html, name) => { + const value = params[name]; + if (!value) return html; + return html + ``; + }, ""); form.style.visibility = "hidden"; document.body.appendChild(form); diff --git a/src/components/Templates/Wizard/useAuthorizedCredential.js b/src/components/Templates/Wizard/useAuthorizedCredential.js index 541690cc..23874447 100644 --- a/src/components/Templates/Wizard/useAuthorizedCredential.js +++ b/src/components/Templates/Wizard/useAuthorizedCredential.js @@ -64,7 +64,7 @@ export default function useAuthorizedCredential({ data = {}, fields = [] }) { setError(false); }, [credential]); - const isOauth = data.schema === "Bearer"; + const isOauth = data.schema === "OAuth"; const authorized = useMemo(() => { if (!isOauth || !!data.refresh_token) return true; @@ -107,27 +107,20 @@ export default function useAuthorizedCredential({ data = {}, fields = [] }) { method: "POST", data: { credential }, }) - .then(({ success, redirect_url }) => { + .then(({ success, data }) => { if (!success) throw "error"; + const { url, params } = data; const form = document.createElement("form"); + form.action = url; form.method = "GET"; - form.action = redirect_url; form.target = "_blank"; - let innerHTML = ` - - - - - - `; - - if (credential.scope) { - innerHTML += ``; - } - - form.innerHTML = innerHTML; + form.innerHTML = Object.keys(params).reduce((html, name) => { + const value = params[name]; + if (!value) return html; + return html + ``; + }, ""); form.style.visibility = "hidden"; document.body.appendChild(form); diff --git a/src/lib/utils.js b/src/lib/utils.js index 46e36131..f8b61c34 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -39,14 +39,6 @@ export function validateBackend(data) { return false; } - if (data.authentication?.type) { - isValid = isValid && data.authentication.client_secret; - - if (data.authentication.type !== "Bearer") { - isValid = isValid && data.authentication.client_id; - } - } - return isValid; } diff --git a/tests/addons/test-bigin.php b/tests/addons/test-bigin.php index d7efe390..026e1793 100644 --- a/tests/addons/test-bigin.php +++ b/tests/addons/test-bigin.php @@ -68,7 +68,7 @@ public static function credentials_provider() { new Credential( array( 'name' => self::CREDENTIAL_NAME, - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'client_id' => 'test-client-id', 'client_secret' => 'test-client-secret', 'region' => 'zoho.eu', diff --git a/tests/addons/test-gcalendar.php b/tests/addons/test-gcalendar.php index ef7dcd3e..6352195b 100644 --- a/tests/addons/test-gcalendar.php +++ b/tests/addons/test-gcalendar.php @@ -40,7 +40,7 @@ public static function credentials_provider() { new Credential( array( 'name' => 'gcalendar-test-credential', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', 'client_id' => 'test-client-id', 'client_secret' => 'test-client-secret', diff --git a/tests/addons/test-gsheets.php b/tests/addons/test-gsheets.php index 8ac9b280..d695644c 100644 --- a/tests/addons/test-gsheets.php +++ b/tests/addons/test-gsheets.php @@ -47,7 +47,7 @@ public static function credentials_provider() { new Credential( array( 'name' => 'gsheets-test-credential', - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'oauth_url' => 'https://accounts.google.com/o/oauth2/v2', 'client_id' => 'test-client-id', 'client_secret' => 'test-client-secret', diff --git a/tests/addons/test-zoho.php b/tests/addons/test-zoho.php index 3060d700..5b95135b 100644 --- a/tests/addons/test-zoho.php +++ b/tests/addons/test-zoho.php @@ -68,7 +68,7 @@ public static function credentials_provider() { new Credential( array( 'name' => self::CREDENTIAL_NAME, - 'schema' => 'Bearer', + 'schema' => 'OAuth', 'client_id' => 'test-client-id', 'client_secret' => 'test-client-secret', 'region' => 'zoho.eu', From d6e63bc903c3cd6d08d372f6b1ad413aa1a55b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 02:56:21 +0100 Subject: [PATCH 02/17] feat: migration 4.3.1 --- forms-bridge/migrations/4.3.1.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 forms-bridge/migrations/4.3.1.php diff --git a/forms-bridge/migrations/4.3.1.php b/forms-bridge/migrations/4.3.1.php new file mode 100644 index 00000000..cd01fb90 --- /dev/null +++ b/forms-bridge/migrations/4.3.1.php @@ -0,0 +1,30 @@ + array(), + 'credentials' => array(), + ); + + foreach ( $http['credentials'] as &$credential ) { + if ( 'Bearer' === $credential['schema'] ) { + $credential['schema'] = 'OAuth'; + } + } + + update_option( 'forms-bridge_http', $http ); +} From 5f66ef340322ca0618f280920c40121f04656cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 03:06:28 +0100 Subject: [PATCH 03/17] fix: credentials test --- forms-bridge/deps/http | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forms-bridge/deps/http b/forms-bridge/deps/http index 28a82700..d6ace505 160000 --- a/forms-bridge/deps/http +++ b/forms-bridge/deps/http @@ -1 +1 @@ -Subproject commit 28a82700bb840f4d9eafd3f93dc4785da4bac68c +Subproject commit d6ace505cccacb6169abca7cf7d47ec517a1a7fa From 685923443995b60dba1f598afbf2ba07c569ab51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 03:09:07 +0100 Subject: [PATCH 04/17] fix: credentials test --- forms-bridge/deps/http | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forms-bridge/deps/http b/forms-bridge/deps/http index d6ace505..2c1815f3 160000 --- a/forms-bridge/deps/http +++ b/forms-bridge/deps/http @@ -1 +1 @@ -Subproject commit d6ace505cccacb6169abca7cf7d47ec517a1a7fa +Subproject commit 2c1815f344ce4126dd8079314b79660f375c983d From 715b863813c9a78925e838ddec593c11b634061e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Tue, 27 Jan 2026 01:17:05 +0100 Subject: [PATCH 05/17] feat: airtable add-on first commit --- forms-bridge/addons/airtable/assets/logo.png | 0 .../addons/airtable/class-airtable-addon.php | 205 ++++++++++++++++++ .../airtable/class-airtable-form-bridge.php | 157 ++++++++++++++ .../airtable/templates/airtable-settings.php | 28 +++ 4 files changed, 390 insertions(+) create mode 100644 forms-bridge/addons/airtable/assets/logo.png create mode 100644 forms-bridge/addons/airtable/class-airtable-addon.php create mode 100644 forms-bridge/addons/airtable/class-airtable-form-bridge.php create mode 100644 forms-bridge/addons/airtable/templates/airtable-settings.php diff --git a/forms-bridge/addons/airtable/assets/logo.png b/forms-bridge/addons/airtable/assets/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php new file mode 100644 index 00000000..8788f1c8 --- /dev/null +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -0,0 +1,205 @@ + '__airtable-' . time(), + 'backend' => $backend, + 'endpoint' => '/', + 'method' => 'GET', + ) + ); + + $backend = $bridge->backend; + if ( ! $backend ) { + Logger::log( 'Airtable backend ping error: Bridge has no valid backend', Logger::ERROR ); + return false; + } + + $credential = $backend->credential; + if ( ! $credential ) { + Logger::log( 'Airtable backend ping error: Backend has no valid credential', Logger::ERROR ); + return false; + } + + $parsed = wp_parse_url( $backend->base_url ); + $host = $parsed['host'] ?? ''; + + if ( 'api.airtable.com' !== $host ) { + Logger::log( 'Airtable backend ping error: Backend does not point to the Airtable API endpoints', Logger::ERROR ); + return false; + } + + $access_token = $credential->get_access_token(); + + if ( ! $access_token ) { + Logger::log( 'Airtable 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 Airtable 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( + 'https://api.airtable.com/v0/meta/bases', + 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 ) { + $response = $this->fetch( null, $backend ); + + if ( is_wp_error( $response ) || empty( $response['data']['bases'] ) ) { + return array(); + } + + return array_map( + function ( $base ) { + return '/v0/' . $base['id'] . '/' . $base['tables'][0]['id']; + }, + $response['data']['bases'] + ); + } + + /** + * Performs an introspection of the backend endpoint and returns API fields + * and accepted content type. + * + * @param string $endpoint Airtable 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 Airtable_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; + } +} + +Airtable_Addon::setup(); diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php new file mode 100644 index 00000000..d5c2a6d4 --- /dev/null +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -0,0 +1,157 @@ +|WP_Error + */ + public function get_fields( $backend = null ) { + if ( ! $this->is_valid ) { + return new WP_Error( 'invalid_bridge' ); + } + + if ( ! $backend ) { + $backend = $this->backend; + } + + $response = $backend->get( $this->endpoint . '?maxRecords=1' ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( empty( $response['data']['fields'] ) ) { + return array(); + } + + return $response['data']['fields']; + } + + /** + * 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['fields'][ $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/airtable/templates/airtable-settings.php b/forms-bridge/addons/airtable/templates/airtable-settings.php new file mode 100644 index 00000000..f14b1298 --- /dev/null +++ b/forms-bridge/addons/airtable/templates/airtable-settings.php @@ -0,0 +1,28 @@ + 'Airtable Settings', + 'description' => 'Configure the Airtable integration.', + 'data' => array( + 'title' => 'Airtable Settings', + 'description' => 'Configure the Airtable integration.', + 'bridges' => array( + array( + 'name' => 'airtable_bridge', + 'title' => 'Airtable Bridge', + 'form_id' => '', + 'backend' => '', + 'method' => 'POST', + 'endpoint' => '', + 'enabled' => true, + 'mutations' => array(), + 'workflow' => array(), + ), + ), + ), +); From 6e23e5137d4c9d90e2566c1c442ebbc43e362d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Tue, 27 Jan 2026 01:25:44 +0100 Subject: [PATCH 06/17] feat: airtable hooks --- forms-bridge/addons/airtable/hooks.php | 135 +++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 forms-bridge/addons/airtable/hooks.php diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php new file mode 100644 index 00000000..957e5477 --- /dev/null +++ b/forms-bridge/addons/airtable/hooks.php @@ -0,0 +1,135 @@ + 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' => 'api_key', + 'label' => __( 'API Key', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'label' => __( 'Endpoint', 'forms-bridge' ), + 'description' => __( 'Format: base_id/table_name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + array( + 'ref' => '#backend', + 'name' => 'name', + 'default' => 'Airtable API', + ), + array( + 'ref' => '#backend', + 'name' => 'base_url', + 'value' => 'https://api.airtable.com', + ), + ), + 'backend' => array( + 'name' => 'Airtable API', + 'base_url' => 'https://api.airtable.com', + 'headers' => array( + array( + 'name' => 'Authorization', + 'value' => 'Bearer {api_key}', + ), + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + ), + ), + 'bridge' => array( + 'backend' => 'Airtable API', + 'endpoint' => '', + ), + 'credential' => array( + 'name' => '', + 'schema' => 'Bearer', + 'api_key' => '', + ), + ), + $defaults, + $schema + ); + + return $defaults; + }, + 10, + 3 +); + +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( strpos( $template_id, 'airtable-' ) !== 0 ) { + return $data; + } + + // Ensure endpoint format is correct for Airtable + if ( ! empty( $data['bridge']['endpoint'] ) && strpos( $data['bridge']['endpoint'], '/' ) === false ) { + $data['bridge']['endpoint'] = '/' . $data['bridge']['endpoint']; + } + return $data; + }, + 10, + 2 +); From 8c34a660c9e33c5b3de4696b5a7e8201285ef1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 02:28:57 +0100 Subject: [PATCH 07/17] feat: airtable addon v2 --- .../addons/airtable/class-airtable-addon.php | 178 ++++++++++-------- .../airtable/class-airtable-form-bridge.php | 94 ++++++--- forms-bridge/addons/airtable/hooks.php | 85 ++++----- .../airtable/templates/airtable-settings.php | 28 --- .../addons/airtable/templates/contacts.php | 54 ++++++ 5 files changed, 256 insertions(+), 183 deletions(-) delete mode 100644 forms-bridge/addons/airtable/templates/airtable-settings.php create mode 100644 forms-bridge/addons/airtable/templates/contacts.php diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index 8788f1c8..47299044 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -1,12 +1,21 @@ '__airtable-' . time(), 'backend' => $backend, - 'endpoint' => '/', + 'endpoint' => '/v0/meta/bases', 'method' => 'GET', ) ); - $backend = $bridge->backend; - if ( ! $backend ) { - Logger::log( 'Airtable backend ping error: Bridge has no valid backend', Logger::ERROR ); - return false; - } - - $credential = $backend->credential; - if ( ! $credential ) { - Logger::log( 'Airtable backend ping error: Backend has no valid credential', Logger::ERROR ); - return false; - } - - $parsed = wp_parse_url( $backend->base_url ); - $host = $parsed['host'] ?? ''; - - if ( 'api.airtable.com' !== $host ) { - Logger::log( 'Airtable backend ping error: Backend does not point to the Airtable API endpoints', Logger::ERROR ); - return false; - } - - $access_token = $credential->get_access_token(); - - if ( ! $access_token ) { + $response = $bridge->submit(); + if ( is_wp_error( $response ) ) { Logger::log( 'Airtable backend ping error: Unable to recover the credential access token', Logger::ERROR ); return false; } @@ -90,32 +78,24 @@ public function ping( $backend ) { * @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' ); - } + $endpoints = $this->get_endpoints( $backend ); - $access_token = $credential->get_access_token(); - if ( ! $access_token ) { - return new WP_Error( 'invalid_credential' ); + if ( is_wp_error( $endpoint ) ) { + return $endpoint; } - $response = http_bridge_get( - 'https://api.airtable.com/v0/meta/bases', - array(), - array( - 'Authorization' => "Bearer {$access_token}", - 'Accept' => 'application/json', - ) + $response = array( + 'data' => array( 'tables' => array() ), ); - if ( is_wp_error( $response ) ) { - return $response; + foreach ( $endpoints as $endpoint ) { + list( $base_id, $table_name ) = array_slice( explode( '/', $endpoint ), 2 ); + + $response['data']['tables'][] = array( + 'endpoint' => $endpoint, + 'name' => $table_name, + 'base' => $base_id, + ); } return $response; @@ -130,18 +110,36 @@ public function fetch( $endpoint, $backend ) { * @return array|WP_Error */ public function get_endpoints( $backend, $method = null ) { - $response = $this->fetch( null, $backend ); + $bridge = new Airtable_Form_Bridge( + array( + 'name' => '__airtable-endpoints', + 'backend' => $backend, + 'endpoint' => '/v0/meta/bases', + 'method' => 'GET', + ), + ); + + $response = $bridge->submit(); if ( is_wp_error( $response ) || empty( $response['data']['bases'] ) ) { return array(); } - return array_map( - function ( $base ) { - return '/v0/' . $base['id'] . '/' . $base['tables'][0]['id']; - }, - $response['data']['bases'] - ); + $endpoints = array(); + foreach ( $response['data']['bases'] as $base ) { + $response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) + ->submit(); + + if ( is_wp_error( $response ) ) { + break; + } + + foreach ( $response['data']['tables'] as $table ) { + $endpoints[] = "/v0/{$base['id']}/{$table['name']}"; + } + } + + return $endpoints; } /** @@ -159,30 +157,14 @@ 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 Airtable_Form_Bridge - */ - $bridge = $candidate; - } - } - - if ( ! isset( $bridge ) ) { - return array(); - } + $bridge = new Airtable_Form_Bridge( + array( + 'name' => '__airtable-endpoint-schema', + 'method' => 'GET', + 'backend' => $backend, + 'endpoint' => $endpoint, + ) + ); $fields = $bridge->get_fields(); @@ -192,9 +174,51 @@ 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', + 'multipleAttachments', + ), + true, + ) + ) { + continue; + } + + switch ( $field['type'] ) { + case 'rating': + case 'number': + $type = 'number'; + break; + case 'checkbox': + $type = 'boolean'; + break; + case 'multipleSelects': + case 'multipleCollaborators': + case 'multipleLookupValues': + case 'multipleRecordLinks': + $type = 'array'; + 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/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index d5c2a6d4..4bdbaea6 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -28,32 +28,74 @@ public function __construct( $data ) { } /** - * Fetches the fields of the Airtable table and returns them as an array. + * Gets the base id from the bridge endpoint. + * + * @return string|null + */ + private function base_id() { + preg_match( '/\/v\d+\/([^\/]+)\/([^\/]+)/', $this->endpoint, $matches ); + + if ( 3 !== count( $matches ) ) { + return null; + } + + return $matches[1]; + } + + /** + * Gets the table id from the bridge endpoint. * - * @param Backend|null $backend Bridge backend instance. + * @return string|null + */ + private function table_id() { + preg_match( '/\/v\d+\/([^\/]+)\/([^\/]+)/', $this->endpoint, $matches ); + + if ( 3 !== count( $matches ) ) { + return null; + } + + return explode( '/', $matches[2] )[0]; + } + + /** + * Fetches the fields of the Airtable 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 ); } - if ( ! $backend ) { - $backend = $this->backend; + $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 ); } - $response = $backend->get( $this->endpoint . '?maxRecords=1' ); + $response = $this->patch( + array( + 'method' => 'GET', + 'endpoint' => "/v0/meta/bases/{$base_id}/tables", + ) + )->submit(); if ( is_wp_error( $response ) ) { return $response; } - if ( empty( $response['data']['fields'] ) ) { - return array(); + foreach ( $response['data']['tables'] as $candidate ) { + if ( $table_id === $candidate['id'] || $table_id === $candidate['name'] ) { + $table = $candidate; + } + } + + if ( ! isset( $table ) ) { + return new WP_Error( 'not_found', 'Table not found', $this->data ); } - return $response['data']['fields']; + return $table['fields']; } /** @@ -78,27 +120,27 @@ 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['fields'][ $field_name ] = $payload[ $field_name ]; - } - } - $endpoint = $this->endpoint; $method = $this->method; if ( 'POST' === $method ) { + $fields = $this->get_fields( $backend ); + if ( is_wp_error( $fields ) ) { + return $fields; + } + + $payload = self::flatten_payload( $payload ); + + $record = array(); + foreach ( $fields as $field ) { + $field_name = $field['name']; + if ( isset( $payload[ $field_name ] ) ) { + $record['fields'][ $field_name ] = $payload[ $field_name ]; + } + } + $payload = array( - 'records' => array( $records ), + 'records' => array( $record ), ); } diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index 957e5477..be909187 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -9,26 +9,6 @@ exit(); } -add_filter( - 'forms_bridge_bridge_schema', - function ( $schema, $addon ) { - if ( 'airtable' !== $addon ) { - return $schema; - } - - $schema['properties']['endpoint']['default'] = '/{base_id}/{table_name}'; - - $schema['properties']['backend']['const'] = 'Airtable API'; - - $schema['properties']['method']['enum'] = array( 'GET', 'POST', 'PUT', 'PATCH' ); - $schema['properties']['method']['default'] = 'POST'; - - return $schema; - }, - 10, - 2 -); - add_filter( 'forms_bridge_template_defaults', function ( $defaults, $addon, $schema ) { @@ -53,20 +33,30 @@ function ( $defaults, $addon, $schema ) { 'value' => 'Bearer', ), array( - 'ref' => '#credential', - 'name' => 'api_key', - 'label' => __( 'API Key', 'forms-bridge' ), - 'type' => 'text', - 'required' => true, - ), - array( - 'ref' => '#bridge', - 'name' => 'endpoint', - 'label' => __( 'Endpoint', 'forms-bridge' ), - 'description' => __( 'Format: base_id/table_name', 'forms-bridge' ), + 'ref' => '#credential', + 'name' => 'access_token', + 'label' => __( 'Access token', 'forms-bridge' ), + 'description' => __( + 'Register your Personal Access Token in the Airtable Builder Hub', + 'forms-bridge' + ), 'type' => 'text', 'required' => true, ), + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'label' => __( 'Table', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + 'options' => array( + 'endpoint' => '/v0/meta/bases', + 'finger' => array( + 'value' => 'tables[].endpoint', + 'label' => 'tables[].name', + ), + ), + ), array( 'ref' => '#bridge', 'name' => 'method', @@ -86,25 +76,16 @@ function ( $defaults, $addon, $schema ) { 'backend' => array( 'name' => 'Airtable API', 'base_url' => 'https://api.airtable.com', - 'headers' => array( - array( - 'name' => 'Authorization', - 'value' => 'Bearer {api_key}', - ), - array( - 'name' => 'Content-Type', - 'value' => 'application/json', - ), - ), ), 'bridge' => array( 'backend' => 'Airtable API', 'endpoint' => '', ), 'credential' => array( - 'name' => '', - 'schema' => 'Bearer', - 'api_key' => '', + 'name' => '', + 'schema' => 'Bearer', + 'access_token' => '', + 'expires_at' => 0, ), ), $defaults, @@ -118,17 +99,17 @@ function ( $defaults, $addon, $schema ) { ); add_filter( - 'forms_bridge_template_data', - function ( $data, $template_id ) { - if ( strpos( $template_id, 'airtable-' ) !== 0 ) { - return $data; + 'http_bridge_oauth_url', + function ( $url, $verb ) { + if ( false === strstr( $url, 'airtable.com' ) ) { + return $url; } - // Ensure endpoint format is correct for Airtable - if ( ! empty( $data['bridge']['endpoint'] ) && strpos( $data['bridge']['endpoint'], '/' ) === false ) { - $data['bridge']['endpoint'] = '/' . $data['bridge']['endpoint']; + if ( 'auth' === $verb ) { + $url .= 'orize'; } - return $data; + + return $url; }, 10, 2 diff --git a/forms-bridge/addons/airtable/templates/airtable-settings.php b/forms-bridge/addons/airtable/templates/airtable-settings.php deleted file mode 100644 index f14b1298..00000000 --- a/forms-bridge/addons/airtable/templates/airtable-settings.php +++ /dev/null @@ -1,28 +0,0 @@ - 'Airtable Settings', - 'description' => 'Configure the Airtable integration.', - 'data' => array( - 'title' => 'Airtable Settings', - 'description' => 'Configure the Airtable integration.', - 'bridges' => array( - array( - 'name' => 'airtable_bridge', - 'title' => 'Airtable Bridge', - 'form_id' => '', - 'backend' => '', - 'method' => 'POST', - 'endpoint' => '', - 'enabled' => true, - 'mutations' => array(), - 'workflow' => array(), - ), - ), - ), -); diff --git a/forms-bridge/addons/airtable/templates/contacts.php b/forms-bridge/addons/airtable/templates/contacts.php new file mode 100644 index 00000000..03882dd4 --- /dev/null +++ b/forms-bridge/addons/airtable/templates/contacts.php @@ -0,0 +1,54 @@ + 'Contacts', + 'description' => 'Simple contact form connected to a Airtable', + 'fields' => array( + array( + 'ref' => '#form', + 'name' => 'title', + 'default' => 'Contacts', + ), + ), + 'form' => array( + 'title' => 'Contacts', + 'fields' => array( + array( + 'name' => 'email', + 'label' => __( 'Your email', 'forms-bridge' ), + 'type' => 'email', + 'required' => true, + ), + array( + 'name' => 'firstname', + 'label' => __( 'Your first name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'name' => 'lastname', + 'label' => __( 'Your last name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'name' => 'phone', + 'label' => 'Your phone', + 'type' => 'tel', + ), + ), + ), + 'bridge' => array( + 'custom_fields' => array( + array( + 'name' => 'language', + 'value' => '$language', + ), + ), + ), +); From 695bd64d0d053e95dc2eb5a8866ec653300fa2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 03:14:14 +0100 Subject: [PATCH 08/17] feat: airtable logo --- forms-bridge/addons/airtable/assets/logo.png | Bin 0 -> 7420 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/forms-bridge/addons/airtable/assets/logo.png b/forms-bridge/addons/airtable/assets/logo.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eeddf6fac88892df3c9b07104b3ffc398b25c611 100644 GIT binary patch literal 7420 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 literal 0 HcmV?d00001 From 2c8837b06e6d367762a667cb3de09bc46dbec7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 05:50:26 +0100 Subject: [PATCH 09/17] feat: dynamic form field templates --- .../addons/airtable/class-airtable-addon.php | 6 +- .../airtable/class-airtable-form-bridge.php | 9 +- forms-bridge/addons/airtable/hooks.php | 121 ++++++++++++++++++ .../addons/airtable/templates/contacts.php | 54 -------- .../addons/airtable/templates/from-table.php | 12 ++ .../gsheets/class-gsheets-form-bridge.php | 2 +- forms-bridge/addons/gsheets/hooks.php | 43 +++++++ ...preadsheet-contacts.json => contacts.json} | 0 .../gsheets/templates/from-spreadsheet.php | 12 ++ forms-bridge/addons/nextcloud/hooks.php | 49 +++++++ ...preadsheet-contacts.json => contacts.json} | 0 .../addons/nextcloud/templates/from-csv.php | 12 ++ .../includes/class-form-bridge-template.php | 8 +- 13 files changed, 263 insertions(+), 65 deletions(-) delete mode 100644 forms-bridge/addons/airtable/templates/contacts.php create mode 100644 forms-bridge/addons/airtable/templates/from-table.php rename forms-bridge/addons/gsheets/templates/{spreadsheet-contacts.json => contacts.json} (100%) create mode 100644 forms-bridge/addons/gsheets/templates/from-spreadsheet.php rename forms-bridge/addons/nextcloud/templates/{spreadsheet-contacts.json => contacts.json} (100%) create mode 100644 forms-bridge/addons/nextcloud/templates/from-csv.php diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index 47299044..ab6ee4b9 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -190,6 +190,9 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { 'rollup', 'externalSyncSource', 'multipleAttachments', + 'multipleCollaborators', + 'multipleLookupValues', + 'multipleRecordLinks', ), true, ) @@ -206,9 +209,6 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { $type = 'boolean'; break; case 'multipleSelects': - case 'multipleCollaborators': - case 'multipleLookupValues': - case 'multipleRecordLinks': $type = 'array'; break; default: diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 4bdbaea6..b4b4dee8 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -163,6 +163,13 @@ private static function flatten_payload( $payload, $path = '' ) { if ( ! is_array( $value ) ) { $flat[ $key ] = $value; + } elseif ( wp_is_numeric_array( $value ) ) { + $flat[ $key ] = array_map( + function ( $value ) { + return array( 'name' => $value ); + }, + $value, + ); } else { foreach ( $value as $_key => $_val ) { $flat[ $_key ] = $_val; @@ -190,7 +197,7 @@ private static function flatten_value( $value, $path = '' ) { $simple_items = array_filter( $value, fn( $item ) => ! is_array( $item ) ); if ( count( $simple_items ) === count( $value ) ) { - return implode( ',', $value ); + return $simple_items; } } diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index be909187..9b55d292 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -5,6 +5,10 @@ * @package formsbridge */ +use FORMS_BRIDGE\Airtable_Form_Bridge; +use HTTP_BRIDGE\Backend; +use HTTP_BRIDGE\Credential; + if ( ! defined( 'ABSPATH' ) ) { exit(); } @@ -98,6 +102,123 @@ function ( $defaults, $addon, $schema ) { 3 ); +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( strpos( $template_id, 'airtable-' ) !== 0 ) { + return $data; + } + + if ( empty( $data['form']['fields'] ) ) { + $credential_data = $data['credential']; + $credential_data['name'] = '__airtable-' . time(); + + Credential::temp_registration( $credential_data ); + + $backend_data = $data['backend']; + $backend_data['credential'] = $credential_data['name']; + $backend_data['name'] = '__airtable-' . time(); + + Backend::temp_registration( $backend_data ); + + $bridge_data = $data['bridge']; + $bridge_data['name'] = '__airtable-' . time(); + $bridge_data['backend'] = $backend_data['name']; + + $bridge = new Airtable_Form_Bridge( $bridge_data ); + + $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', + 'multipleAttachments', + 'multipleCollaborators', + 'multipleLookupValues', + 'multipleRecordLinks', + ), + true, + ) + ) { + continue; + } + + $field_name = sanitize_title( $field['name'] ); + $form_field = array( + 'name' => $field_name, + 'label' => $field['name'], + ); + + switch ( $field['type'] ) { + 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; + } + + $data['form']['fields'][] = $form_field; + + if ( $field['name'] !== $form_field['name'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $form_field['name'], + 'to' => $field['name'], + 'cast' => 'inherit', + ); + } + } + } + } + + return $data; + }, + 10, + 2, +); + add_filter( 'http_bridge_oauth_url', function ( $url, $verb ) { diff --git a/forms-bridge/addons/airtable/templates/contacts.php b/forms-bridge/addons/airtable/templates/contacts.php deleted file mode 100644 index 03882dd4..00000000 --- a/forms-bridge/addons/airtable/templates/contacts.php +++ /dev/null @@ -1,54 +0,0 @@ - 'Contacts', - 'description' => 'Simple contact form connected to a Airtable', - 'fields' => array( - array( - 'ref' => '#form', - 'name' => 'title', - 'default' => 'Contacts', - ), - ), - 'form' => array( - 'title' => 'Contacts', - 'fields' => array( - array( - 'name' => 'email', - 'label' => __( 'Your email', 'forms-bridge' ), - 'type' => 'email', - 'required' => true, - ), - array( - 'name' => 'firstname', - 'label' => __( 'Your first name', 'forms-bridge' ), - 'type' => 'text', - 'required' => true, - ), - array( - 'name' => 'lastname', - 'label' => __( 'Your last name', 'forms-bridge' ), - 'type' => 'text', - 'required' => true, - ), - array( - 'name' => 'phone', - 'label' => 'Your phone', - 'type' => 'tel', - ), - ), - ), - 'bridge' => array( - 'custom_fields' => array( - array( - 'name' => 'language', - 'value' => '$language', - ), - ), - ), -); diff --git a/forms-bridge/addons/airtable/templates/from-table.php b/forms-bridge/addons/airtable/templates/from-table.php new file mode 100644 index 00000000..202c9f42 --- /dev/null +++ b/forms-bridge/addons/airtable/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() ), +); diff --git a/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php b/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php index 8d855fde..67893a2c 100644 --- a/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php +++ b/forms-bridge/addons/gsheets/class-gsheets-form-bridge.php @@ -84,7 +84,7 @@ private function value_range( $values ) { * * @param Backend|null $backend Bridge backend instance. * - * @return array + * @return array|WP_Error */ public function get_headers( $backend = null ) { if ( ! $this->is_valid ) { diff --git a/forms-bridge/addons/gsheets/hooks.php b/forms-bridge/addons/gsheets/hooks.php index 4bd3c509..91b79e6f 100644 --- a/forms-bridge/addons/gsheets/hooks.php +++ b/forms-bridge/addons/gsheets/hooks.php @@ -5,6 +5,9 @@ * @package formsbridge */ +use FORMS_BRIDGE\GSheets_Form_Bridge; +use HTTP_BRIDGE\Backend; + if ( ! defined( 'ABSPATH' ) ) { exit(); } @@ -172,6 +175,46 @@ function ( $data, $template_id ) { } $data['bridge']['endpoint'] = '/v4/spreadsheets/' . $data['bridge']['endpoint']; + + if ( empty( $data['form']['fields'] ) ) { + $backend_data = $data['backend']; + $backend_data['credential'] = $data['credential']['name']; + $backend_data['name'] = '__gsheets-' . time(); + + Backend::temp_registration( $backend_data ); + + $bridge_data = $data['bridge']; + $bridge_data['name'] = '__gsheets-' . time(); + $bridge_data['backend'] = $backend_data['name']; + + $bridge = new GSheets_Form_Bridge( $bridge_data ); + + $headers = $bridge->get_headers(); + if ( ! is_wp_error( $headers ) ) { + foreach ( $headers as $header ) { + $field_name = sanitize_title( $header ); + + $data['form']['fields'][] = array( + 'name' => $field_name, + 'label' => $header, + 'type' => 'text', + ); + + if ( $field_name !== $header ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field_name, + 'to' => $header, + 'cast' => 'inherit', + ); + } + } + } + } + return $data; }, 10, diff --git a/forms-bridge/addons/gsheets/templates/spreadsheet-contacts.json b/forms-bridge/addons/gsheets/templates/contacts.json similarity index 100% rename from forms-bridge/addons/gsheets/templates/spreadsheet-contacts.json rename to forms-bridge/addons/gsheets/templates/contacts.json diff --git a/forms-bridge/addons/gsheets/templates/from-spreadsheet.php b/forms-bridge/addons/gsheets/templates/from-spreadsheet.php new file mode 100644 index 00000000..7bd79e52 --- /dev/null +++ b/forms-bridge/addons/gsheets/templates/from-spreadsheet.php @@ -0,0 +1,12 @@ + __( 'From spreadsheet', 'forms-bridge' ), + 'description' => __( 'Create a bridge and a form based on an existing spreadsheet', 'forms-bridge' ), + 'form' => array( 'fields' => array() ), +); diff --git a/forms-bridge/addons/nextcloud/hooks.php b/forms-bridge/addons/nextcloud/hooks.php index b5ba18bb..5efedb51 100644 --- a/forms-bridge/addons/nextcloud/hooks.php +++ b/forms-bridge/addons/nextcloud/hooks.php @@ -5,6 +5,10 @@ * @package formsbridge */ +use FORMS_BRIDGE\Nextcloud_Form_Bridge; +use HTTP_BRIDGE\Backend; +use HTTP_BRIDGE\Credential; + if ( ! defined( 'ABSPATH' ) ) { exit(); } @@ -138,6 +142,51 @@ function ( $data, $template_id ) { $data['bridge']['endpoint'] .= '.csv'; } + if ( empty( $data['form']['fields'] ) ) { + $credential_data = $data['credential']; + $credential_data['name'] = '__nextcloud-' . time(); + + Credential::temp_registration( $credential_data ); + + $backend_data = $data['backend']; + $backend_data['credential'] = $credential_data['name']; + $backend_data['name'] = '__nextcloud-' . time(); + + Backend::temp_registration( $backend_data ); + + $bridge_data = $data['bridge']; + $bridge_data['name'] = '__nextcloud-' . time(); + $bridge_data['backend'] = $backend_data['name']; + + $bridge = new Nextcloud_Form_Bridge( $bridge_data ); + + $headers = $bridge->table_headers(); + + if ( is_array( $headers ) ) { + foreach ( $headers as $header ) { + $field_name = sanitize_title( $header ); + + $data['form']['fields'][] = array( + 'name' => $field_name, + 'label' => $header, + 'type' => 'text', + ); + + if ( $header !== $field_name ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field_name, + 'to' => $header, + 'cast' => 'inherit', + ); + } + } + } + } + return $data; }, 10, diff --git a/forms-bridge/addons/nextcloud/templates/spreadsheet-contacts.json b/forms-bridge/addons/nextcloud/templates/contacts.json similarity index 100% rename from forms-bridge/addons/nextcloud/templates/spreadsheet-contacts.json rename to forms-bridge/addons/nextcloud/templates/contacts.json diff --git a/forms-bridge/addons/nextcloud/templates/from-csv.php b/forms-bridge/addons/nextcloud/templates/from-csv.php new file mode 100644 index 00000000..915add98 --- /dev/null +++ b/forms-bridge/addons/nextcloud/templates/from-csv.php @@ -0,0 +1,12 @@ + __( 'From CSV', 'forms-bridge' ), + 'description' => __( 'Create a bridge and a form based on an existing CSV table', 'forms-bridge' ), + 'form' => array( 'fields' => array() ), +); diff --git a/forms-bridge/includes/class-form-bridge-template.php b/forms-bridge/includes/class-form-bridge-template.php index 2af69b0d..f9eca618 100644 --- a/forms-bridge/includes/class-form-bridge-template.php +++ b/forms-bridge/includes/class-form-bridge-template.php @@ -970,10 +970,7 @@ static function ( $field ) { if ( ! $form_id ) { return new WP_Error( 'form_creation_error', - __( - 'Forms bridge can\'t create the form', - 'forms-bridge' - ), + __( 'Forms bridge can\'t create the form', 'forms-bridge' ), array( 'status' => 400, 'data' => $data['form'], @@ -991,8 +988,7 @@ static function ( $field ) { ); } - $data['bridge']['form_id'] = - $integration . ':' . $data['form']['id']; + $data['bridge']['form_id'] = $integration . ':' . $data['form']['id']; $create_credential = false; if ( ! empty( $data['credential']['name'] ) ) { From 6a4d55d27f5cb826f5ef65d93829c7a2f8272c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Wed, 28 Jan 2026 05:57:07 +0100 Subject: [PATCH 10/17] fix: skip internat field props on wpcf7 field to tag --- .../wpcf7/class-wpcf7-integration.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php index cbf2e283..a9ea3487 100644 --- a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php +++ b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php @@ -509,7 +509,24 @@ private function field_to_tag( $field ) { $tag = "[{$type} {$name} "; foreach ( $field as $key => $val ) { - if ( ! in_array( $key, array( 'name', 'type', 'value', 'required', 'label' ), true ) ) { + if ( + $val && + ! in_array( + $key, + array( + 'name', + 'type', + 'value', + 'required', + 'label', + 'is_multi', + 'is_file', + 'conditional', + 'options', + ), + true, + ) + ) { $key = sanitize_text_field( $key ); $val = trim( sanitize_text_field( $val ) ); $tag .= "{$key}:{$val} "; From 7e4d827c80154d39a76117c114846f7c0b637aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 10:14:19 +0100 Subject: [PATCH 11/17] feat: nextcloud get endpoints --- .../nextcloud/class-nextcloud-addon.php | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/forms-bridge/addons/nextcloud/class-nextcloud-addon.php b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php index e30d4588..c4763272 100644 --- a/forms-bridge/addons/nextcloud/class-nextcloud-addon.php +++ b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php @@ -8,6 +8,7 @@ namespace FORMS_BRIDGE; use FBAPI; +use SimpleXMLElement; if ( ! defined( 'ABSPATH' ) ) { exit(); @@ -107,6 +108,82 @@ public function fetch( $endpoint, $backend ) { return array(); } + /** + * 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 ) { + if ( ! class_exists( 'SimpleXMLElement' ) ) { + return array(); + } + + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return array(); + } + + $credential = $backend->credential; + if ( ! $credential ) { + return array(); + } + + $authorization = $credential->authorization(); + if ( ! $authorization ) { + return array(); + } + + $url = $backend->url( '/remote.php/dav/files/' . rawurlencode( $credential->client_id ) ); + + $response = wp_remote_request( + $url, + array( + 'method' => 'PROPFIND', + 'headers' => array( + 'Depth' => '5', + 'Authorization' => $authorization, + 'Content-Type' => 'text/xml', + ), + 'body' => '' + . '' + . '' + . '', + ) + ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $xml = new SimpleXMLElement( $response['body'] ); + $xml->registerXPathNamespace( 'd', 'DAV:' ); + + $parsed_url = parse_url( $url ); + $basepath = $parsed_url['path'] ?? '/'; + + $endpoints = array(); + foreach ( $xml->xpath( '//d:response' ) as $item ) { + $href = (string) $item->children( 'DAV:' )->href; + $endpoint = rawurldecode( str_replace( $basepath, '', $href ) ); + + if ( '/' === $endpoint ) { + continue; + } + + $pathinfo = pathinfo( $endpoint ); + if ( isset( $pathinfo['extension'] ) && 'csv' !== strtolower( $pathinfo['extension'] ) ) { + continue; + } + + $endpoints[] = substr( $endpoint, 1 ); + } + + return $endpoints; + } + /** * Performs an introspection of the backend model and returns API fields * and accepted content type. From c399c21a1389895a071f5620e07d7aa965d88826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 12:37:46 +0100 Subject: [PATCH 12/17] feat: nextcloud fetch csv files --- .../nextcloud/class-nextcloud-addon.php | 71 ++++++++++++------- forms-bridge/addons/nextcloud/hooks.php | 7 ++ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/forms-bridge/addons/nextcloud/class-nextcloud-addon.php b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php index c4763272..4e261b0a 100644 --- a/forms-bridge/addons/nextcloud/class-nextcloud-addon.php +++ b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php @@ -9,6 +9,7 @@ use FBAPI; use SimpleXMLElement; +use WP_Error; if ( ! defined( 'ABSPATH' ) ) { exit(); @@ -105,35 +106,23 @@ public function ping( $backend ) { * @return array|WP_Error */ public function fetch( $endpoint, $backend ) { - return array(); - } - - /** - * 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 ) { if ( ! class_exists( 'SimpleXMLElement' ) ) { - return array(); + return new WP_Error( 'xml_not_supported', 'Requires phpxml extension to be enabled' ); } $backend = FBAPI::get_backend( $backend ); if ( ! $backend ) { - return array(); + return new WP_Error( 'invalid_backend', 'Backend not found', array( 'backend' => $backend ) ); } $credential = $backend->credential; if ( ! $credential ) { - return array(); + return new WP_Error( 'invalid_backend', 'Backend has no credential', $backend->data() ); } $authorization = $credential->authorization(); if ( ! $authorization ) { - return array(); + return new WP_Error( 'invalid_credential', 'Credential has no authorization', $credential->data() ); } $url = $backend->url( '/remote.php/dav/files/' . rawurlencode( $credential->client_id ) ); @@ -155,30 +144,64 @@ public function get_endpoints( $backend, $method = null ) { ); if ( is_wp_error( $response ) ) { - return array(); + return $response; } $xml = new SimpleXMLElement( $response['body'] ); $xml->registerXPathNamespace( 'd', 'DAV:' ); - $parsed_url = parse_url( $url ); + $parsed_url = wp_parse_url( $url ); $basepath = $parsed_url['path'] ?? '/'; - $endpoints = array(); + $files = array(); foreach ( $xml->xpath( '//d:response' ) as $item ) { $href = (string) $item->children( 'DAV:' )->href; - $endpoint = rawurldecode( str_replace( $basepath, '', $href ) ); + $filepath = rawurldecode( str_replace( $basepath, '', $href ) ); + + if ( '/' === $filepath ) { + continue; + } + + $pathinfo = pathinfo( $filepath ); + $is_file = isset( $pathinfo['extension'] ); - if ( '/' === $endpoint ) { + if ( $is_file && 'csv' !== strtolower( $pathinfo['extension'] ) ) { continue; } - $pathinfo = pathinfo( $endpoint ); - if ( isset( $pathinfo['extension'] ) && 'csv' !== strtolower( $pathinfo['extension'] ) ) { + if ( ! $is_file && 'files' === $endpoint ) { + continue; + } elseif ( $is_file && 'directories' === $endpoint ) { continue; } - $endpoints[] = substr( $endpoint, 1 ); + $files[] = array( + 'path' => substr( $filepath, 1 ), + 'is_file' => $is_file, + ); + } + + return array( 'data' => array( 'files' => $files ) ); + } + + /** + * 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 ) { + $response = $this->fetch( 'endpoints', $backend ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $endpoints = array(); + foreach ( $response['data']['files'] as $file ) { + $endpoints[] = $file['path']; } return $endpoints; diff --git a/forms-bridge/addons/nextcloud/hooks.php b/forms-bridge/addons/nextcloud/hooks.php index 5efedb51..76e75862 100644 --- a/forms-bridge/addons/nextcloud/hooks.php +++ b/forms-bridge/addons/nextcloud/hooks.php @@ -75,6 +75,13 @@ function ( $defaults, $addon, $schema ) { 'name' => 'endpoint', 'label' => __( 'Filepath', 'forms-bridge' ), 'pattern' => '.+\.csv$', + 'options' => array( + 'endpoint' => 'files', + 'finger' => array( + 'label' => 'files[].path', + 'value' => 'files[].path', + ), + ), ), array( 'ref' => '#credential', From 0aace00cf63eef1e06d396f4ca9aadd54855cede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 12:38:15 +0100 Subject: [PATCH 13/17] feat: artable fetch table labels --- .../addons/airtable/class-airtable-addon.php | 70 +++++++++---------- forms-bridge/addons/airtable/hooks.php | 2 +- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index ab6ee4b9..2220a265 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -78,27 +78,43 @@ public function ping( $backend ) { * @return array|WP_Error */ public function fetch( $endpoint, $backend ) { - $endpoints = $this->get_endpoints( $backend ); + $bridge = new Airtable_Form_Bridge( + array( + 'name' => '__airtable-meta-bases', + 'backend' => $backend, + 'endpoint' => '/v0/meta/bases', + 'method' => 'GET', + ), + ); + + $response = $bridge->submit(); - if ( is_wp_error( $endpoint ) ) { - return $endpoint; + if ( is_wp_error( $response ) ) { + return $response; } - $response = array( - 'data' => array( 'tables' => array() ), - ); + $tables = array(); + foreach ( $response['data']['bases'] as $base ) { + $schema_response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) + ->submit(); - foreach ( $endpoints as $endpoint ) { - list( $base_id, $table_name ) = array_slice( explode( '/', $endpoint ), 2 ); + if ( is_wp_error( $schema_response ) ) { + return $schema_response; + } - $response['data']['tables'][] = array( - 'endpoint' => $endpoint, - 'name' => $table_name, - 'base' => $base_id, - ); + foreach ( $schema_response['data']['tables'] as $table ) { + $tables[] = array( + 'base_id' => $base['id'], + 'base_name' => $base['name'], + 'label' => "{$base['name']}/{$table['name']}", + 'name' => $table['name'], + 'id' => $table['id'], + 'endpoint' => "/v0/{$base['id']}/{$table['name']}", + ); + } } - return $response; + return array( 'data' => array( 'tables' => $tables ) ); } /** @@ -110,33 +126,15 @@ public function fetch( $endpoint, $backend ) { * @return array|WP_Error */ public function get_endpoints( $backend, $method = null ) { - $bridge = new Airtable_Form_Bridge( - array( - 'name' => '__airtable-endpoints', - 'backend' => $backend, - 'endpoint' => '/v0/meta/bases', - 'method' => 'GET', - ), - ); - - $response = $bridge->submit(); + $response = $this->fetch( null, $backend ); - if ( is_wp_error( $response ) || empty( $response['data']['bases'] ) ) { + if ( is_wp_error( $response ) ) { return array(); } $endpoints = array(); - foreach ( $response['data']['bases'] as $base ) { - $response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) - ->submit(); - - if ( is_wp_error( $response ) ) { - break; - } - - foreach ( $response['data']['tables'] as $table ) { - $endpoints[] = "/v0/{$base['id']}/{$table['name']}"; - } + foreach ( $response['data']['tables'] as $table ) { + $endpoints[] = $table['endpoint']; } return $endpoints; diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index 9b55d292..32a3f8df 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -57,7 +57,7 @@ function ( $defaults, $addon, $schema ) { 'endpoint' => '/v0/meta/bases', 'finger' => array( 'value' => 'tables[].endpoint', - 'label' => 'tables[].name', + 'label' => 'tables[].label', ), ), ), From db5c714a3ba137a98f1e382a7a1ad55f17c6b34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 16:50:34 +0100 Subject: [PATCH 14/17] feat: airtable attachments --- .../addons/airtable/class-airtable-addon.php | 4 +- .../airtable/class-airtable-form-bridge.php | 139 +++++++++++------- forms-bridge/addons/airtable/hooks.php | 13 +- 3 files changed, 102 insertions(+), 54 deletions(-) diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index 2220a265..844943b9 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -187,7 +187,6 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { 'lastModifiedTime', 'rollup', 'externalSyncSource', - 'multipleAttachments', 'multipleCollaborators', 'multipleLookupValues', 'multipleRecordLinks', @@ -209,6 +208,9 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { case 'multipleSelects': $type = 'array'; break; + case 'multipleAttachments': + $type = 'file'; + break; 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 b4b4dee8..4a1e3fa8 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -7,6 +7,7 @@ namespace FORMS_BRIDGE; +use FBAPI; use WP_Error; if ( ! defined( 'ABSPATH' ) ) { @@ -88,6 +89,7 @@ public function get_fields() { foreach ( $response['data']['tables'] as $candidate ) { if ( $table_id === $candidate['id'] || $table_id === $candidate['name'] ) { $table = $candidate; + break; } } @@ -129,12 +131,56 @@ public function submit( $payload = array(), $attachments = array() ) { return $fields; } - $payload = self::flatten_payload( $payload ); + $data_fields = array(); + $attachments = array(); + + $l = count( $fields ); + for ( $i = 0; $i < $l; ++$i ) { + if ( 'multipleAttachments' === $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( + 'id' => $attachment_field['id'], + 'file' => $payload[ $attachment_name ], + 'name' => $attachment_name, + 'key' => $key, + ); + + unset( $payload[ $key ] ); + unset( $payload[ $key . '_filename' ] ); + } + } else { + $data_fields[] = $fields[ $i ]; + } + } $record = array(); - foreach ( $fields as $field ) { - $field_name = $field['name']; + foreach ( $data_fields as $data_field ) { + $field_name = $data_field['name']; + if ( isset( $payload[ $field_name ] ) ) { + if ( 'multipleSelects' === $data_field['type'] ) { + if ( ! is_array( $payload[ $field_name ] ) ) { + $payload[ $field_name ] = array( $payload[ $field_name ] ); + } + + $l = count( $payload[ $field_name ] ); + for ( $i = 0; $i < $l; ++$i ) { + $payload[ $field_name ][ $i ] = array( 'name' => $payload[ $field_name ][ $i ] ); + } + } + $record['fields'][ $field_name ] = $payload[ $field_name ]; } } @@ -144,63 +190,52 @@ public function submit( $payload = array(), $attachments = array() ) { ); } - return $this->backend->$method( $endpoint, $payload ); - } + $response = $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; - } elseif ( wp_is_numeric_array( $value ) ) { - $flat[ $key ] = array_map( - function ( $value ) { - return array( 'name' => $value ); - }, - $value, - ); - } else { - foreach ( $value as $_key => $_val ) { - $flat[ $_key ] = $_val; - } - } + if ( is_wp_error( $response ) || empty( $response['data']['records'] ) ) { + return $response; } - return $flat; - } + if ( 'POST' === $method && count( $attachments ) ) { + $base_id = $this->base_id(); + $record_id = $response['data']['records'][0]['id']; - /** - * 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; - } + $uploads = Forms_Bridge::attachments( FBAPI::get_uploads() ); + + foreach ( $attachments as $attachment ) { + $filetype = array( 'type' => 'octet/stream' ); + $filename = $attachment['name']; + + foreach ( $uploads as $upload_name => $path ) { + if ( $upload_name === $attachment['key'] || $upload_name === sanitize_title( $attachment['key'] ) ) { + $filename = basename( $path ); + $filetype = wp_check_filetype( $path ); + if ( empty( $filetype['type'] ) ) { + $filetype['type'] = mime_content_type( $path ); + } + } + } - if ( wp_is_numeric_array( $value ) ) { - $simple_items = array_filter( $value, fn( $item ) => ! is_array( $item ) ); + $upload_response = $backend->clone( + array( + 'name' => '__airtable-uploader', + 'base_url' => 'https://content.airtable.com', + ) + )->post( + "/v0/{$base_id}/{$record_id}/{$attachment['id']}/uploadAttachment", + array( + 'contentType' => $filetype['type'], + 'file' => $attachment['file'], + 'filename' => $filename, + ) + ); - if ( count( $simple_items ) === count( $value ) ) { - return $simple_items; + if ( is_wp_error( $upload_response ) ) { + return $upload_response; + } } } - return self::flatten_payload( $value, $path . '.' ); + return $response; } } diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index 32a3f8df..a60cc27a 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -145,7 +145,6 @@ function ( $data, $template_id ) { 'lastModifiedTime', 'rollup', 'externalSyncSource', - 'multipleAttachments', 'multipleCollaborators', 'multipleLookupValues', 'multipleRecordLinks', @@ -163,6 +162,10 @@ function ( $data, $template_id ) { ); switch ( $field['type'] ) { + case 'multipleAttachments': + $form_field['type'] = 'file'; + $form_field['is_multi'] = true; + break; case 'rating': case 'number': $form_field['type'] = 'number'; @@ -203,6 +206,14 @@ function ( $choice ) { $data['bridge']['mutations'][0] = array(); } + if ( 'file' === $form_field['type'] ) { + $data['bridge']['mutations'][0][] = array( + 'from' => $form_field['name'] . '_filename', + 'to' => $field['name'], + 'cast' => 'null', + ); + } + $data['bridge']['mutations'][0][] = array( 'from' => $form_field['name'], 'to' => $field['name'], From d3c99da07697893a8404663ba142dc7a6979f63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 17:10:17 +0100 Subject: [PATCH 15/17] fix: airtable multiSelects payload value formatting --- .../addons/airtable/class-airtable-form-bridge.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 4a1e3fa8..7faa0b48 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -170,15 +170,8 @@ function ( $name ) use ( $attachment_name ) { $field_name = $data_field['name']; if ( isset( $payload[ $field_name ] ) ) { - if ( 'multipleSelects' === $data_field['type'] ) { - if ( ! is_array( $payload[ $field_name ] ) ) { - $payload[ $field_name ] = array( $payload[ $field_name ] ); - } - - $l = count( $payload[ $field_name ] ); - for ( $i = 0; $i < $l; ++$i ) { - $payload[ $field_name ][ $i ] = array( 'name' => $payload[ $field_name ][ $i ] ); - } + if ( 'multipleSelects' === $data_field['type'] && ! is_array( $payload[ $field_name ] ) ) { + $payload[ $field_name ] = array( $payload[ $field_name ] ); } $record['fields'][ $field_name ] = $payload[ $field_name ]; From 82ec7fc65ab520b1a7775336001b94831fb04fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 17:50:09 +0100 Subject: [PATCH 16/17] feat: airtable test case --- tests/addons/test-airtable.php | 611 +++++++++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 tests/addons/test-airtable.php diff --git a/tests/addons/test-airtable.php b/tests/addons/test-airtable.php new file mode 100644 index 00000000..125c328c --- /dev/null +++ b/tests/addons/test-airtable.php @@ -0,0 +1,611 @@ + 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' => 'Accept', + 'value' => 'application/json', + ), + ), + ) + ), + ); + } + + /** + * 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 ) { + if ( 0 === strpos( $path, '/v0/meta/bases' ) ) { + // Meta bases endpoint. + if ( '/v0/meta/bases' === $path ) { + return array( + 'bases' => array( + array( + 'id' => 'app123456789', + 'name' => 'Test Base', + ), + array( + 'id' => 'app987654321', + 'name' => 'Another Base', + ), + ), + ); + } elseif ( preg_match( '#/v0/meta/bases/(app\d+)/tables#', $path, $matches ) ) { + // Tables for a specific base. + return array( + 'tables' => array( + array( + 'id' => 'tbl123456789', + 'name' => 'Contacts', + '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', + ), + array( + 'id' => 'fld999999999', + 'name' => 'Summary', + 'type' => 'aiText', + ), + ), + ), + array( + 'id' => 'tbl987654321', + 'name' => 'Projects', + 'fields' => array( + array( + 'id' => 'field1234', + 'name' => 'Name', + 'type' => 'singleLineText', + 'options' => array(), + ), + ), + ), + ), + ); + } + } elseif ( preg_match( '#/v0/(app\d+)/([^/]+)#', $path, $matches ) ) { + // Table data endpoint. + if ( 'GET' === $method ) { + // Mock field schema for GET requests. + return array( + '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', + ); + } + } + + // 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 addon class exists and has correct constants. + */ + public function test_addon_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Airtable_Addon' ) ); + $this->assertEquals( 'Airtable', Airtable_Addon::TITLE ); + $this->assertEquals( 'airtable', Airtable_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\Airtable_Form_Bridge', Airtable_Addon::BRIDGE ); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Airtable_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + 'method' => 'POST', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new Airtable_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 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', + ); + + $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'] ); + } + + /** + * Test GET request to fetch table schema. + */ + public function test_get_table_schema() { + $bridge = new Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit(); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'fields', $response['data'] ); + $this->assertCount( 5, $response['data']['fields'] ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'airtable' ); + $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( 'airtable' ); + $response = $addon->fetch( null, self::BACKEND_NAME ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'tables', $response['data'] ); + $this->assertCount( 4, $response['data']['tables'] ); // 2 bases Γ— 2 tables each + + // Check that tables have the expected structure. + $table = $response['data']['tables'][0]; + $this->assertArrayHasKey( 'base_id', $table ); + $this->assertArrayHasKey( 'base_name', $table ); + $this->assertArrayHasKey( 'label', $table ); + $this->assertArrayHasKey( 'id', $table ); + $this->assertArrayHasKey( 'endpoint', $table ); + $this->assertEquals( 'Test Base/Contacts', $table['label'] ); + $this->assertEquals( '/v0/app123456789/Contacts', $table['endpoint'] ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'airtable' ); + $endpoints = $addon->get_endpoints( self::BACKEND_NAME ); + + $this->assertIsArray( $endpoints ); + $this->assertNotEmpty( $endpoints ); + $this->assertContains( '/v0/app123456789/Contacts', $endpoints ); + $this->assertContains( '/v0/app123456789/Projects', $endpoints ); + $this->assertContains( '/v0/app987654321/Contacts', $endpoints ); + $this->assertContains( '/v0/app987654321/Projects', $endpoints ); + } + + /** + * Test addon get_endpoint_schema method for POST. + */ + public function test_addon_get_endpoint_schema_post() { + $addon = Addon::addon( 'airtable' ); + $schema = $addon->get_endpoint_schema( + '/v0/app123456789/Contacts', + 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( 'Score', $field_names ); + $this->assertContains( 'Tags', $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( 'number', $schema_map['Score'] ); + $this->assertEquals( 'array', $schema_map['Tags'] ); + } + + /** + * Test addon get_endpoint_schema method for non-POST methods. + */ + public function test_addon_get_endpoint_schema_non_post() { + $addon = Addon::addon( 'airtable' ); + $schema = $addon->get_endpoint_schema( + '/v0/app123456789/Contacts', + 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' => array( + 'type' => 'AUTHENTICATION_REQUIRED', + 'message' => 'Authentication required', + ), + ); + + $bridge = new Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Airtable_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/v0/app123456789/Contacts', + '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 Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + '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() { + // 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', + 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( 'Summary', $field_names ); + $this->assertCount( 5, $schema ); + } +} From 81cfebbca6a22eb48a0553e9470049aaeb134e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Garc=C3=ADa?= Date: Thu, 29 Jan 2026 17:54:18 +0100 Subject: [PATCH 17/17] feat: update readmes --- README.md | 1 + forms-bridge/readme.txt | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 523ffbff..cc162717 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ 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/) - [Nextcloud](https://formsbridge.codeccoop.org/documentation/nextcloud/) diff --git a/forms-bridge/readme.txt b/forms-bridge/readme.txt index f59f2e95..029d421d 100644 --- a/forms-bridge/readme.txt +++ b/forms-bridge/readme.txt @@ -22,7 +22,7 @@ Whether you use Zoho, Odoo, Dolibarr, Zulip, or a custom backend, Forms Bridge m βœ… No code required – Set up integrations with a user-friendly interface. βœ… Works with your favorite form plugins – Contact Form 7, Gravity Forms, WPForms, Ninja Forms, WooCommerce, and Formidable Forms. -βœ… 15+ ready-to-use add-ons – Connect to Zoho, Odoo, Dolibarr, Google Sheets, Slack, Mailchimp, and more. +βœ… 20+ ready-to-use add-ons – Connect to Zoho, Odoo, Dolibarr, Google Sheets, Slack, Mailchimp, and more. βœ… Advanced data mapping – Rename, transform, and enrich form data before sending it. βœ… Workflow automation – Pre-process submissions with custom jobs. βœ… Reusable templates – Get started in minutes with pre-built blueprints. @@ -161,6 +161,12 @@ You can get support from CΓ²dec using the [Forms Bridge support forum](https://w == Changelog == += 4.3.1 = +feat: airtable add-on +feat: dynamic form field templates for google sheets, airtable and nextcloud +feat: nextcloud get endpoints method +fix: skip internal field attributes in wpcf7 form creation + = 4.3.0 = * feat: form's bridge chain order * feat: bridge failure policy