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/addons/airtable/assets/logo.png b/forms-bridge/addons/airtable/assets/logo.png new file mode 100644 index 00000000..eeddf6fa Binary files /dev/null and b/forms-bridge/addons/airtable/assets/logo.png differ 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..844943b9 --- /dev/null +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -0,0 +1,229 @@ + '__airtable-' . time(), + 'backend' => $backend, + 'endpoint' => '/v0/meta/bases', + 'method' => 'GET', + ) + ); + + $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; + } + + 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 ) { + $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( $response ) ) { + return $response; + } + + $tables = array(); + foreach ( $response['data']['bases'] as $base ) { + $schema_response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) + ->submit(); + + if ( is_wp_error( $schema_response ) ) { + return $schema_response; + } + + 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 array( 'data' => array( 'tables' => $tables ) ); + } + + /** + * 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 ) ) { + return array(); + } + + $endpoints = array(); + foreach ( $response['data']['tables'] as $table ) { + $endpoints[] = $table['endpoint']; + } + + return $endpoints; + } + + /** + * 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 = new Airtable_Form_Bridge( + array( + 'name' => '__airtable-endpoint-schema', + 'method' => 'GET', + 'backend' => $backend, + 'endpoint' => $endpoint, + ) + ); + + $fields = $bridge->get_fields(); + + if ( is_wp_error( $fields ) ) { + return array(); + } + + $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'; + break; + case 'multipleAttachments': + $type = 'file'; + break; + default: + $type = 'string'; + break; + } + + $schema[] = array( + 'name' => $field['name'], + 'schema' => array( 'type' => $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..7faa0b48 --- /dev/null +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -0,0 +1,234 @@ +endpoint, $matches ); + + if ( 3 !== count( $matches ) ) { + return null; + } + + return $matches[1]; + } + + /** + * Gets the table id from the bridge endpoint. + * + * @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() { + if ( ! $this->is_valid ) { + return new WP_Error( 'invalid_bridge', 'The bridge is 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 ); + } + + $response = $this->patch( + array( + 'method' => 'GET', + 'endpoint' => "/v0/meta/bases/{$base_id}/tables", + ) + )->submit(); + + if ( is_wp_error( $response ) ) { + return $response; + } + + foreach ( $response['data']['tables'] as $candidate ) { + if ( $table_id === $candidate['id'] || $table_id === $candidate['name'] ) { + $table = $candidate; + break; + } + } + + if ( ! isset( $table ) ) { + return new WP_Error( 'not_found', 'Table not found', $this->data ); + } + + return $table['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' ); + } + + $endpoint = $this->endpoint; + $method = $this->method; + + if ( 'POST' === $method ) { + $fields = $this->get_fields( $backend ); + if ( is_wp_error( $fields ) ) { + return $fields; + } + + $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 ( $data_fields as $data_field ) { + $field_name = $data_field['name']; + + if ( isset( $payload[ $field_name ] ) ) { + if ( 'multipleSelects' === $data_field['type'] && ! is_array( $payload[ $field_name ] ) ) { + $payload[ $field_name ] = array( $payload[ $field_name ] ); + } + + $record['fields'][ $field_name ] = $payload[ $field_name ]; + } + } + + $payload = array( + 'records' => array( $record ), + ); + } + + $response = $backend->$method( $endpoint, $payload ); + + if ( is_wp_error( $response ) || empty( $response['data']['records'] ) ) { + return $response; + } + + if ( 'POST' === $method && count( $attachments ) ) { + $base_id = $this->base_id(); + $record_id = $response['data']['records'][0]['id']; + + $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 ); + } + } + } + + $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 ( is_wp_error( $upload_response ) ) { + return $upload_response; + } + } + } + + return $response; + } +} diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php new file mode 100644 index 00000000..a60cc27a --- /dev/null +++ b/forms-bridge/addons/airtable/hooks.php @@ -0,0 +1,248 @@ + 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 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[].label', + ), + ), + ), + 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', + ), + 'bridge' => array( + 'backend' => 'Airtable 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 ( 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', + '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; + } + + $data['form']['fields'][] = $form_field; + + if ( $field['name'] !== $form_field['name'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $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'], + 'cast' => 'inherit', + ); + } + } + } + } + + return $data; + }, + 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/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/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/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 1bc263d5..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(); } @@ -59,7 +62,7 @@ function ( $defaults, $addon, $schema ) { 'ref' => '#credential', 'name' => 'schema', 'type' => 'text', - 'value' => 'Bearer', + 'value' => 'OAuth', ), array( 'ref' => '#credential', @@ -144,7 +147,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' => '', @@ -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/class-nextcloud-addon.php b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php index e30d4588..4e261b0a 100644 --- a/forms-bridge/addons/nextcloud/class-nextcloud-addon.php +++ b/forms-bridge/addons/nextcloud/class-nextcloud-addon.php @@ -8,6 +8,8 @@ namespace FORMS_BRIDGE; use FBAPI; +use SimpleXMLElement; +use WP_Error; if ( ! defined( 'ABSPATH' ) ) { exit(); @@ -104,7 +106,105 @@ public function ping( $backend ) { * @return array|WP_Error */ public function fetch( $endpoint, $backend ) { - return array(); + if ( ! class_exists( 'SimpleXMLElement' ) ) { + return new WP_Error( 'xml_not_supported', 'Requires phpxml extension to be enabled' ); + } + + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend not found', array( 'backend' => $backend ) ); + } + + $credential = $backend->credential; + if ( ! $credential ) { + return new WP_Error( 'invalid_backend', 'Backend has no credential', $backend->data() ); + } + + $authorization = $credential->authorization(); + if ( ! $authorization ) { + return new WP_Error( 'invalid_credential', 'Credential has no authorization', $credential->data() ); + } + + $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 $response; + } + + $xml = new SimpleXMLElement( $response['body'] ); + $xml->registerXPathNamespace( 'd', 'DAV:' ); + + $parsed_url = wp_parse_url( $url ); + $basepath = $parsed_url['path'] ?? '/'; + + $files = array(); + foreach ( $xml->xpath( '//d:response' ) as $item ) { + $href = (string) $item->children( 'DAV:' )->href; + $filepath = rawurldecode( str_replace( $basepath, '', $href ) ); + + if ( '/' === $filepath ) { + continue; + } + + $pathinfo = pathinfo( $filepath ); + $is_file = isset( $pathinfo['extension'] ); + + if ( $is_file && 'csv' !== strtolower( $pathinfo['extension'] ) ) { + continue; + } + + if ( ! $is_file && 'files' === $endpoint ) { + continue; + } elseif ( $is_file && 'directories' === $endpoint ) { + continue; + } + + $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 b5ba18bb..76e75862 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(); } @@ -71,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', @@ -138,6 +149,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/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..2c1815f3 160000 --- a/forms-bridge/deps/http +++ b/forms-bridge/deps/http @@ -1 +1 @@ -Subproject commit 225bbf917cd8df2713398f7081f1f43afd7bafd0 +Subproject commit 2c1815f344ce4126dd8079314b79660f375c983d 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'] ) ) { 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} "; 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 ); +} 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 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-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 ); + } +} 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',