diff --git a/README.md b/README.md index cc162717..3aaf0142 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,10 @@ Forms Bridge has the following add-ons: **🗓️ Productivity** -- [Airtable](https://formsbridge.codeccoop.org/documentation/airtable) +- [Airtable](https://formsbridge.codeccoop.org/documentation/airtable/) - [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) - [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) +- [Grist](https://formsbridge.codeccoop.org/documentation/grist/) - [Nextcloud](https://formsbridge.codeccoop.org/documentation/nextcloud/) **📨 Messaging & Collaboration** diff --git a/forms-bridge/addons/airtable/class-airtable-addon.php b/forms-bridge/addons/airtable/class-airtable-addon.php index 844943b9..74d1370a 100644 --- a/forms-bridge/addons/airtable/class-airtable-addon.php +++ b/forms-bridge/addons/airtable/class-airtable-addon.php @@ -51,18 +51,16 @@ class Airtable_Addon extends Addon { * @return boolean */ public function ping( $backend ) { - $bridge = new Airtable_Form_Bridge( - array( - 'name' => '__airtable-' . time(), - 'backend' => $backend, - 'endpoint' => '/v0/meta/bases', - 'method' => 'GET', - ) - ); + $backend = FBAPI::get_backend( $backend ); - $response = $bridge->submit(); + if ( ! $backend ) { + Logger::log( 'Airtable backend ping error: Backend is unkown or invalid', Logger::ERROR ); + return false; + } + + $response = $backend->get( '/v0/meta/bases' ); if ( is_wp_error( $response ) ) { - Logger::log( 'Airtable backend ping error: Unable to recover the credential access token', Logger::ERROR ); + Logger::log( 'Airtable backend ping error: Unable to list airtable bases', Logger::ERROR ); return false; } @@ -78,16 +76,16 @@ public function ping( $backend ) { * @return array|WP_Error */ public function fetch( $endpoint, $backend ) { - $bridge = new Airtable_Form_Bridge( - array( - 'name' => '__airtable-meta-bases', - 'backend' => $backend, - 'endpoint' => '/v0/meta/bases', - 'method' => 'GET', - ), - ); + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend is unkown or invalid', array( 'backend' => $backend ) ); + } - $response = $bridge->submit(); + if ( $endpoint && '/v0/meta/tables' !== $endpoint ) { + return $backend->get( $endpoint ); + } + + $response = $backend->get( '/v0/meta/bases' ); if ( is_wp_error( $response ) ) { return $response; @@ -95,8 +93,7 @@ public function fetch( $endpoint, $backend ) { $tables = array(); foreach ( $response['data']['bases'] as $base ) { - $schema_response = $bridge->patch( array( 'endpoint' => "/v0/meta/bases/{$base['id']}/tables" ) ) - ->submit(); + $schema_response = $backend->get( "/v0/meta/bases/{$base['id']}/tables" ); if ( is_wp_error( $schema_response ) ) { return $schema_response; @@ -172,45 +169,20 @@ public function get_endpoint_schema( $endpoint, $backend, $method = null ) { $schema = array(); foreach ( $fields as $field ) { - if ( - in_array( - $field['type'], - array( - 'aiText', - 'formula', - 'autoNumber', - 'button', - 'count', - 'createdBy', - 'createdTime', - 'lastModifiedBy', - 'lastModifiedTime', - 'rollup', - 'externalSyncSource', - 'multipleCollaborators', - 'multipleLookupValues', - 'multipleRecordLinks', - ), - true, - ) - ) { - continue; - } - switch ( $field['type'] ) { - case 'rating': case 'number': $type = 'number'; break; case 'checkbox': $type = 'boolean'; break; - case 'multipleSelects': - $type = 'array'; + case 'select': + $type = $field['is_multi'] ? 'array' : 'string'; break; - case 'multipleAttachments': + case 'file': $type = 'file'; break; + case 'textarea': default: $type = 'string'; break; diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 7faa0b48..4afd2311 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -68,19 +68,20 @@ public function get_fields() { return new WP_Error( 'invalid_bridge', 'The bridge is invalid', $this->data ); } + $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'The bridge backend is unkown or invalid', $this->data ); + } + $base_id = $this->base_id(); $table_id = $this->table_id(); if ( ! $base_id || ! $table_id ) { - return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); + return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); } - $response = $this->patch( - array( - 'method' => 'GET', - 'endpoint' => "/v0/meta/bases/{$base_id}/tables", - ) - )->submit(); + $endpoint = "/v0/meta/bases/{$base_id}/tables"; + $response = $backend->get( $endpoint ); if ( is_wp_error( $response ) ) { return $response; @@ -97,7 +98,81 @@ public function get_fields() { return new WP_Error( 'not_found', 'Table not found', $this->data ); } - return $table['fields']; + $fields = array(); + foreach ( $table['fields'] as $air_field ) { + if ( + in_array( + $air_field['type'], + array( + 'aiText', + 'formula', + 'autoNumber', + 'button', + 'count', + 'createdBy', + 'createdTime', + 'lastModifiedBy', + 'lastModifiedTime', + 'rollup', + 'externalSyncSource', + 'multipleCollaborators', + 'multipleLookupValues', + 'multipleRecordLinks', + ), + true, + ) + ) { + continue; + } + + $field = array( + 'id' => $air_field['id'], + 'name' => $air_field['name'], + 'label' => $air_field['name'], + ); + + switch ( $air_field['type'] ) { + case 'multipleAttachments': + $field['type'] = 'file'; + $field['is_multi'] = true; + break; + case 'rating': + case 'number': + $field['type'] = 'number'; + break; + case 'checkbox': + $field['type'] = 'checkbox'; + break; + case 'multipleSelects': + case 'singleSelect': + $field['type'] = 'select'; + $field['options'] = array_map( + function ( $choice ) { + return array( + 'value' => $choice['name'], + 'label' => $choice['name'], + ); + }, + $air_field['options']['choices'], + ); + + $field['is_multi'] = 'multipleSelects' === $air_field['type']; + break; + case 'date': + $field['type'] = 'date'; + break; + case 'multilineText': + $field['type'] = 'textarea'; + break; + default: + $field['type'] = 'text'; + break; + } + + $fields[] = $field; + } + + return $fields; } /** @@ -136,7 +211,7 @@ public function submit( $payload = array(), $attachments = array() ) { $l = count( $fields ); for ( $i = 0; $i < $l; ++$i ) { - if ( 'multipleAttachments' === $fields[ $i ]['type'] ) { + if ( 'file' === $fields[ $i ]['type'] ) { $attachment_field = $fields[ $i ]; $attachment_name = $attachment_field['name']; @@ -170,7 +245,11 @@ function ( $name ) use ( $attachment_name ) { $field_name = $data_field['name']; if ( isset( $payload[ $field_name ] ) ) { - if ( 'multipleSelects' === $data_field['type'] && ! is_array( $payload[ $field_name ] ) ) { + if ( + 'select' === $data_field['type'] + && $data_field['is_multi'] + && ! is_array( $payload[ $field_name ] ) + ) { $payload[ $field_name ] = array( $payload[ $field_name ] ); } @@ -204,7 +283,7 @@ function ( $name ) use ( $attachment_name ) { $filename = basename( $path ); $filetype = wp_check_filetype( $path ); if ( empty( $filetype['type'] ) ) { - $filetype['type'] = mime_content_type( $path ); + $filetype['type'] = mime_content_type( $path ) ?: 'octet/stream'; } } } @@ -217,10 +296,10 @@ function ( $name ) use ( $attachment_name ) { )->post( "/v0/{$base_id}/{$record_id}/{$attachment['id']}/uploadAttachment", array( - 'contentType' => $filetype['type'], + 'contentType' => $filetype['type'] ?? 'octet/stream', 'file' => $attachment['file'], 'filename' => $filename, - ) + ), ); if ( is_wp_error( $upload_response ) ) { diff --git a/forms-bridge/addons/airtable/hooks.php b/forms-bridge/addons/airtable/hooks.php index a60cc27a..5436086a 100644 --- a/forms-bridge/addons/airtable/hooks.php +++ b/forms-bridge/addons/airtable/hooks.php @@ -47,6 +47,12 @@ function ( $defaults, $addon, $schema ) { 'type' => 'text', 'required' => true, ), + array( + 'ref' => '#credential', + 'name' => 'expires_at', + 'type' => 'number', + 'value' => time() + 60 * 60 * 24 * 365 * 100, + ), array( 'ref' => '#bridge', 'name' => 'endpoint', @@ -54,7 +60,7 @@ function ( $defaults, $addon, $schema ) { 'type' => 'text', 'required' => true, 'options' => array( - 'endpoint' => '/v0/meta/bases', + 'endpoint' => '/v0/meta/tables', 'finger' => array( 'value' => 'tables[].endpoint', 'label' => 'tables[].label', @@ -105,7 +111,7 @@ function ( $defaults, $addon, $schema ) { add_filter( 'forms_bridge_template_data', function ( $data, $template_id ) { - if ( strpos( $template_id, 'airtable-' ) !== 0 ) { + if ( 0 !== strpos( $template_id, 'airtable-' ) ) { return $data; } @@ -130,95 +136,42 @@ function ( $data, $template_id ) { $fields = $bridge->get_fields(); if ( ! is_wp_error( $fields ) ) { foreach ( $fields as $field ) { - if ( - in_array( - $field['type'], - array( - 'aiText', - 'formula', - 'autoNumber', - 'button', - 'count', - 'createdBy', - 'createdTime', - 'lastModifiedBy', - 'lastModifiedTime', - 'rollup', - 'externalSyncSource', - 'multipleCollaborators', - 'multipleLookupValues', - 'multipleRecordLinks', - ), - true, - ) - ) { - continue; - } - - $field_name = sanitize_title( $field['name'] ); - $form_field = array( - 'name' => $field_name, - 'label' => $field['name'], - ); - - switch ( $field['type'] ) { - case 'multipleAttachments': - $form_field['type'] = 'file'; - $form_field['is_multi'] = true; - break; - case 'rating': - case 'number': - $form_field['type'] = 'number'; - break; - case 'checkbox': - $form_field['type'] = 'checkbox'; - break; - case 'multipleSelects': - case 'singleSelect': - $form_field['type'] = 'select'; - $form_field['options'] = array_map( - function ( $choice ) { - return array( - 'value' => $choice['name'], - 'label' => $choice['name'], - ); - }, - $field['options']['choices'], - ); - - $form_field['is_multi'] = 'multipleSelects' === $field['type']; - break; - case 'date': - $form_field['type'] = 'date'; - break; - case 'multilineText': - $form_field['type'] = 'textarea'; - break; - default: - $form_field['type'] = 'text'; - break; + $field_name = $field['name']; + $sanitized = sanitize_title( $field_name ); + if ( strtolower( $field_name ) !== $sanitized ) { + $field['name'] = $sanitized; } - $data['form']['fields'][] = $form_field; + $data['form']['fields'][] = $field; - if ( $field['name'] !== $form_field['name'] ) { + if ( $field['label'] !== $field['name'] ) { if ( ! isset( $data['bridge']['mutations'][0] ) ) { $data['bridge']['mutations'][0] = array(); } - if ( 'file' === $form_field['type'] ) { + if ( 'file' === $field['type'] ) { $data['bridge']['mutations'][0][] = array( - 'from' => $form_field['name'] . '_filename', - 'to' => $field['name'], + 'from' => $field['name'] . '_filename', + 'to' => $field_name . '_filename', 'cast' => 'null', ); } $data['bridge']['mutations'][0][] = array( - 'from' => $form_field['name'], - 'to' => $field['name'], + 'from' => $field['name'], + 'to' => $field_name, 'cast' => 'inherit', ); + } elseif ( 'file' === $field['type'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field_name . '_filename', + 'cast' => 'null', + ); } } } @@ -229,20 +182,3 @@ function ( $choice ) { 10, 2, ); - -add_filter( - 'http_bridge_oauth_url', - function ( $url, $verb ) { - if ( false === strstr( $url, 'airtable.com' ) ) { - return $url; - } - - if ( 'auth' === $verb ) { - $url .= 'orize'; - } - - return $url; - }, - 10, - 2 -); diff --git a/forms-bridge/addons/grist/assets/logo.png b/forms-bridge/addons/grist/assets/logo.png new file mode 100644 index 00000000..8c69a95c Binary files /dev/null and b/forms-bridge/addons/grist/assets/logo.png differ diff --git a/forms-bridge/addons/grist/class-grist-addon.php b/forms-bridge/addons/grist/class-grist-addon.php new file mode 100644 index 00000000..83d5d1cf --- /dev/null +++ b/forms-bridge/addons/grist/class-grist-addon.php @@ -0,0 +1,224 @@ +get( '/api/orgs' ); + + if ( is_wp_error( $response ) ) { + Logger::log( 'Grist backend ping error: Unable to list grist organizations', Logger::ERROR ); + return false; + } + + return true; + } + + /** + * Performs a GET request against the backend endpoint and retrive the response data. + * + * @param string $endpoint Grist endpoint. + * @param string $backend Backend name. + * + * @return array|WP_Error + */ + public function fetch( $endpoint, $backend ) { + $backend = FBAPI::get_backend( $backend ); + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'Backend is unkown or invalid', array( 'backend' => $backend ) ); + } + + if ( $endpoint && '/api/orgs/{orgId}/tables' !== $endpoint ) { + return $backend->get( $endpoint ); + } + + if ( preg_match( '/[^\/]+(?=\.getgrist.com)/', $backend->base_url, $matches ) ) { + $org_id = $matches[0]; + } + + if ( ! isset( $org_id ) ) { + foreach ( $backend->headers as $header => $value ) { + if ( 'orgid' === strtolower( $header ) ) { + $org_id = $value; + break; + } + } + } + + if ( ! isset( $org_id ) ) { + return new WP_Error( 'invalid_backend', 'Backend does not have the orgId header', $backend->data() ); + } + + $response = $backend->get( "/api/orgs/{$org_id}/workspaces" ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $tables = array(); + foreach ( $response['data'] as $workspace ) { + foreach ( $workspace['docs'] as $doc ) { + $docs_response = $backend->get( "/api/docs/{$doc['id']}/tables" ); + + if ( is_wp_error( $docs_response ) ) { + continue; + } + + foreach ( $docs_response['data']['tables'] as $table ) { + $tables[] = array( + 'org_id' => $org_id, + 'doc_id' => $doc['urlId'], + 'doc_name' => $doc['name'], + 'id' => $table['id'], + 'label' => "{$doc['name']}/{$table['id']}", + 'endpoint' => "/api/docs/{$doc['urlId']}/tables/{$table['id']}/records", + ); + } + } + } + + return array( 'data' => array( 'tables' => $tables ) ); + } + + /** + * 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 Grist endpoint. + * @param string $backend Backend name. + * @param string|null $method HTTP method. + * + * @return array List of fields and content type of the endpoint. + */ + public function get_endpoint_schema( $endpoint, $backend, $method = null ) { + if ( 'POST' !== $method ) { + return array(); + } + + $bridge = new Grist_Form_Bridge( + array( + 'name' => '__grist-endpoint-introspection', + 'backend' => $backend, + 'endpoint' => $endpoint, + 'method' => 'GET', + ) + ); + + if ( ! $bridge->is_valid ) { + return array(); + } + + $fields = $bridge->get_fields(); + + if ( is_wp_error( $fields ) ) { + return array(); + } + + $schema = array(); + foreach ( $fields as $field ) { + switch ( $field['type'] ) { + case 'number': + $type = 'number'; + break; + case 'checkbox': + $type = 'boolean'; + break; + case 'select': + $type = $field['is_multi'] ? 'array' : 'string'; + break; + case 'file': + $type = 'file'; + break; + default: + $type = 'string'; + break; + } + + $schema[] = array( + 'name' => $field['name'], + 'schema' => array( 'type' => $type ), + ); + } + + return $schema; + } +} + +Grist_Addon::setup(); diff --git a/forms-bridge/addons/grist/class-grist-form-bridge.php b/forms-bridge/addons/grist/class-grist-form-bridge.php new file mode 100644 index 00000000..2edd2acf --- /dev/null +++ b/forms-bridge/addons/grist/class-grist-form-bridge.php @@ -0,0 +1,268 @@ +endpoint, $matches ); + + if ( empty( $matches[0] ) ) { + return null; + } + + return $matches[0]; + } + + /** + * Gets the table id from the bridge endpoint. + * + * @return string|null + */ + private function table_id() { + preg_match( '/(?<=tables\/)[^\/]+/', $this->endpoint, $matches ); + + if ( empty( $matches[0] ) ) { + return null; + } + + return $matches[0]; + } + + /** + * Fetches the fields of the Grist table and returns them as an array. + * + * @return array|WP_Error + */ + public function get_fields() { + if ( ! $this->is_valid ) { + return new WP_Error( 'invalid_bridge', 'The bridge is invalid', $this->data ); + } + + $backend = $this->backend; + if ( ! $backend ) { + return new WP_Error( 'invalid_backend', 'The bridge backend is unkown or invalid', $this->data ); + } + + $doc_id = $this->doc_id(); + $table_id = $this->table_id(); + + if ( ! $doc_id || ! $table_id ) { + return new WP_Error( 'invalid_endpoint', 'The bridge has an invalid endpoint', $this->data ); + } + + $endpoint = "/api/docs/{$doc_id}/tables/{$table_id}/columns"; + $response = $backend->get( $endpoint ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $fields = array(); + foreach ( $response['data']['columns'] as $column ) { + if ( + $column['fields']['isFormula'] + || $column['fields']['formula'] + || 0 === strpos( $column['fields']['type'], 'Ref:' ) + || 0 === strpos( $column['fields']['type'], 'RefList:' ) + ) { + continue; + } + + $field = array( + 'name' => $column['id'], + 'label' => $column['fields']['label'], + ); + + switch ( $column['fields']['type'] ) { + case 'Attachments': + $field['type'] = 'file'; + break; + case 'Choice': + case 'ChoiceList': + $field['type'] = 'select'; + $field['is_multi'] = 'ChoiceList' === $column['fields']['type']; + + $options = json_decode( $column['fields']['widgetOptions'], true ) ?: array( 'choices' => array() ); + $field['options'] = array_map( + function ( $choice ) { + return array( + 'value' => $choice, + 'label' => $choice, + ); + }, + $options['choices'], + ); + + break; + case 'Bool': + $field['type'] = 'checkbox'; + break; + case 'Int': + case 'Numeric': + $field['type'] = 'number'; + break; + case 'Date': + $field['type'] = 'date'; + break; + default: + $field['type'] = 'text'; + break; + } + + $fields[] = $field; + } + + return $fields; + } + + /** + * 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 ( 'file' === $fields[ $i ]['type'] ) { + $attachment_field = $fields[ $i ]; + $attachment_name = $attachment_field['name']; + + $names = array_keys( $payload ); + $keys = array_filter( + $names, + function ( $name ) use ( $attachment_name ) { + $name = preg_replace( '/_\d+$/', '', $name ); + return $name === $attachment_name; + } + ); + + foreach ( $keys as $key ) { + $attachments[] = array( + 'file' => $payload[ $attachment_name ], + 'name' => $attachment_name, + 'key' => $key, + ); + + unset( $payload[ $key ] ); + unset( $payload[ $key . '_filename' ] ); + } + } else { + $data_fields[] = $fields[ $i ]; + } + } + + $record = array( 'fields' => array() ); + + if ( count( $attachments ) ) { + $doc_id = $this->doc_id(); + + $uploads = Forms_Bridge::attachments( FBAPI::get_uploads() ); + + foreach ( $attachments as $attachment ) { + foreach ( $uploads as $upload_name => $path ) { + if ( $upload_name === $attachment['key'] || sanitize_title( $attachment['key'] ) === $upload_name ) { + $attachment_path = $path; + break; + } + } + + if ( ! isset( $attachment_path ) || ! is_file( $attachment_path ) ) { + continue; + } + + $upload_response = $backend->post( + "/api/docs/{$doc_id}/attachments", + array(), + array( 'Content-Type' => 'multipart/form-data' ), + array( 'upload' => $attachment_path ), + ); + + if ( is_wp_error( $upload_response ) ) { + return $upload_response; + } + + $record['fields'][ $attachment['name'] ] = $upload_response['data'][0]; + } + } + + foreach ( $data_fields as $field ) { + $field_name = $field['name']; + if ( isset( $payload[ $field_name ] ) ) { + if ( 'select' === $field['type'] && ( $field['is_multi'] ?? false ) ) { + if ( ! is_array( $payload[ $field_name ] ) ) { + $payload[ $field_name ] = array( $payload[ $field_name ] ); + } + + array_unshift( $payload[ $field_name ], 'L' ); + } + + $record['fields'][ $field_name ] = $payload[ $field_name ]; + } + } + + $payload = array( + 'records' => array( $record ), + ); + } + + return $this->backend->$method( $endpoint, $payload ); + } +} diff --git a/forms-bridge/addons/grist/hooks.php b/forms-bridge/addons/grist/hooks.php new file mode 100644 index 00000000..d2ea5deb --- /dev/null +++ b/forms-bridge/addons/grist/hooks.php @@ -0,0 +1,193 @@ + array( + array( + 'ref' => '#credential', + 'name' => 'name', + 'label' => __( 'Name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'schema', + 'type' => 'text', + 'value' => 'Bearer', + ), + array( + 'ref' => '#credential', + 'name' => 'access_token', + 'label' => __( 'Access token', 'forms-bridge' ), + 'description' => __( + 'Register your Personal Access Token in your Grist account settings page', + 'forms-bridge' + ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'expires_at', + 'type' => 'number', + 'value' => time() + 60 * 60 * 24 * 365 * 100, + ), + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'label' => __( 'Table', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + 'options' => array( + 'endpoint' => '/api/orgs/{orgId}/tables', + 'finger' => array( + 'value' => 'tables[].endpoint', + 'label' => 'tables[].label', + ), + ), + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + array( + 'ref' => '#backend', + 'name' => 'name', + 'default' => 'Grist API', + ), + array( + 'ref' => '#backend', + 'name' => 'base_url', + 'default' => 'https://docs.getgrist.com', + ), + array( + 'ref' => '#backend/headers[]', + 'name' => 'orgId', + 'label' => __( 'Team ID', 'forms-bridge' ), + 'description' => __( + 'Use `docs` by default for personal sites. If you\'ve created team site, it should be the team subdomain (e.g. `example` from https://example.getgrist.com). In self-hosted instances, the team ID is the last part of the team\'s homepage URL (e.g. `example` from http://localhost:8484/o/example)', + 'forms-bridge', + ), + 'type' => 'text', + 'required' => true, + 'default' => 'docs', + ), + ), + 'backend' => array(), + 'bridge' => array( + 'backend' => 'Grist API', + 'endpoint' => '', + ), + 'credential' => array( + 'name' => '', + 'schema' => 'Bearer', + 'access_token' => '', + 'expires_at' => 0, + ), + ), + $defaults, + $schema + ); + + return $defaults; + }, + 10, + 3 +); + +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( 0 !== strpos( $template_id, 'grist-' ) ) { + return $data; + } + + if ( empty( $data['form']['fields'] ) ) { + $credential_data = $data['credential']; + $credential_data['name'] = '__grist-' . time(); + + Credential::temp_registration( $credential_data ); + + $backend_data = $data['backend']; + $backend_data['credential'] = $credential_data['name']; + $backend_data['name'] = '__grist-' . time(); + + Backend::temp_registration( $backend_data ); + + $bridge_data = $data['bridge']; + $bridge_data['name'] = '__grist-' . time(); + $bridge_data['backend'] = $backend_data['name']; + + $bridge = new Grist_Form_Bridge( $bridge_data ); + + $fields = $bridge->get_fields(); + if ( ! is_wp_error( $fields ) ) { + foreach ( $fields as $field ) { + $field_name = $field['name']; + $sanitized = sanitize_title( $field_name ); + if ( strtolower( $field_name ) !== $sanitized ) { + $field['name'] = $sanitized; + } + + $data['form']['fields'][] = $field; + + if ( $field['name'] !== $field_name ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + if ( 'file' === $field['type'] ) { + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field['name'], + 'cast' => 'null', + ); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'], + 'to' => $field['name'], + 'cast' => 'inherit', + ); + } elseif ( 'file' === $field['type'] ) { + if ( ! isset( $data['bridge']['mutations'][0] ) ) { + $data['bridge']['mutations'][0] = array(); + } + + $data['bridge']['mutations'][0][] = array( + 'from' => $field['name'] . '_filename', + 'to' => $field['name'], + 'cast' => 'null', + ); + } + } + } + } + + return $data; + }, + 10, + 2, +); diff --git a/forms-bridge/addons/grist/templates/from-table.php b/forms-bridge/addons/grist/templates/from-table.php new file mode 100644 index 00000000..62e65cce --- /dev/null +++ b/forms-bridge/addons/grist/templates/from-table.php @@ -0,0 +1,12 @@ + __( 'From table', 'forms-bridge' ), + 'description' => __( 'Create a bridge and a form based on an existing table', 'forms-bridge' ), + 'form' => array( 'fields' => array() ), +); diff --git a/forms-bridge/includes/class-forms-bridge.php b/forms-bridge/includes/class-forms-bridge.php index ff670892..2030dd1c 100644 --- a/forms-bridge/includes/class-forms-bridge.php +++ b/forms-bridge/includes/class-forms-bridge.php @@ -491,7 +491,7 @@ private static function prune_empties( $submission_data ) { * * @return array Map of uploaded files. */ - public static function attachments( $uploads ) { + public static function attachments( $uploads = array() ) { $attachments = array(); foreach ( $uploads as $name => $upload ) { diff --git a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php index a9ea3487..4039de00 100644 --- a/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php +++ b/forms-bridge/integrations/wpcf7/class-wpcf7-integration.php @@ -441,6 +441,8 @@ protected function submission_uploads( $submission ) { 'path' => $is_multi ? $paths : $paths[0], 'is_multi' => $is_multi, ); + } else { + unset( $uploads[ $file_name ] ); } } diff --git a/forms-bridge/readme.txt b/forms-bridge/readme.txt index 7426185e..ef768474 100644 --- a/forms-bridge/readme.txt +++ b/forms-bridge/readme.txt @@ -70,8 +70,10 @@ Forms Bridge has the following add-ons: **🗓️ Productivity** +* [Airtable](https://formsbridge.codeccoop.org/documentation/airtable/) * [Google Calendar](https://formsbridge.codeccoop.org/documentation/google-calendar/) * [Google Sheets](https://formsbridge.codeccoop.org/documentation/google-sheets/) +* [Grist](https://formsbridge.codeccoop.org/documentation/grist/) * [Nextcloud](https://formsbridge.codeccoop.org/documentation/nextcloud/) **📨 Messaging & Collaboration** @@ -93,7 +95,7 @@ Think of a bridge as a pipeline through which your form submissions data flows t Think of it as a "connection profile" for your API. Save your API URL, headers, and credentials once, then reuse them across all your bridges. No need to re-enter details every time! **Credentials** -Authenticate your HTTP requests using API keys, Basic Auth, OAuth, RPC credentials and Digest Auth. +Authenticate your HTTP requests using API keys, Basic Auth, Bearer Tokens, OAuth, RPC credentials and Digest Auth. **Custom fields & mappers** Add hidden data (like user IDs or product codes) and rename/transform fields to match your API’s requirements. @@ -137,7 +139,7 @@ Maybe a little understanding about how HTTP and HTTP-like APIs works will be req = What if my API requires authentication? = -Forms Bridge supports API keys, Basic Auth, OAuth, RPC credentials, Digest Auth and custom headers. +Forms Bridge supports API keys, Basic Auth, Bearer Tokens, OAuth, RPC credentials, Digest Auth and custom headers. = Is there a free trial? = @@ -163,9 +165,12 @@ You can get support from Còdec using the [Forms Bridge support forum](https://w = 4.3.1 = feat: airtable add-on +feat: grist add-on feat: dynamic form field templates for google sheets, airtable and nextcloud feat: nextcloud get endpoints method +feat: rename bearer to oauth and new support for bearer tokens fix: skip internal field attributes in wpcf7 form creation + = 4.3.0 = * feat: form's bridge chain order diff --git a/tests/addons/test-airtable.php b/tests/addons/test-airtable.php index 125c328c..6a77b6ec 100644 --- a/tests/addons/test-airtable.php +++ b/tests/addons/test-airtable.php @@ -184,7 +184,7 @@ private static function get_mock_response( $method, $path, $body ) { ), ), ); - } elseif ( preg_match( '#/v0/meta/bases/(app\d+)/tables#', $path, $matches ) ) { + } elseif ( preg_match( '#^/v0/meta/bases/(app\d+)/tables$#', $path, $matches ) ) { // Tables for a specific base. return array( 'tables' => array( @@ -213,15 +213,36 @@ private static function get_mock_response( $method, $path, $body ) { 'type' => 'number', ), array( - 'id' => 'fld888888888', - 'name' => 'Tags', - 'type' => 'multipleSelects', + 'id' => 'fld888888888', + 'name' => 'Tags', + 'type' => 'multipleSelects', + 'options' => array( + 'choices' => array( + array( + 'name' => 'A', + 'id' => 't1', + ), + array( + 'name' => 'B', + 'id' => 't2', + ), + array( + 'name' => 'C', + 'id' => 't3', + ), + ), + ), ), array( 'id' => 'fld999999999', 'name' => 'Summary', 'type' => 'aiText', ), + array( + 'id' => 'fld2222222222', + 'name' => 'Profile Picture', + 'type' => 'multipleAttachments', + ), ), ), array( @@ -239,45 +260,73 @@ private static function get_mock_response( $method, $path, $body ) { ), ); } - } elseif ( preg_match( '#/v0/(app\d+)/([^/]+)#', $path, $matches ) ) { + } elseif ( preg_match( '#^/v0/(app\d+)/([^/]+)$#', $path, $matches ) ) { // Table data endpoint. if ( 'GET' === $method ) { // Mock field schema for GET requests. return array( - 'fields' => array( + 'records' => array( array( - 'id' => 'fld123456789', - 'name' => 'Name', - 'type' => 'singleLineText', - ), - array( - 'id' => 'fld987654321', - 'name' => 'Email', - 'type' => 'email', - ), - array( - 'id' => 'fld555555555', - 'name' => 'Active', - 'type' => 'checkbox', - ), - array( - 'id' => 'fld777777777', - 'name' => 'Score', - 'type' => 'number', - ), - array( - 'id' => 'fld888888888', - 'name' => 'Tags', - 'type' => 'multipleSelects', + 'id' => 'rec123456789', + 'createdTime' => '2023-01-01T00:00:00.000Z', + 'fields' => array( + array( + 'id' => 'fld123456789', + 'name' => 'Name', + 'type' => 'singleLineText', + ), + array( + 'id' => 'fld987654321', + 'name' => 'Email', + 'type' => 'email', + ), + array( + 'id' => 'fld555555555', + 'name' => 'Active', + 'type' => 'checkbox', + ), + array( + 'id' => 'fld777777777', + 'name' => 'Score', + 'type' => 'number', + ), + array( + 'id' => 'fld888888888', + 'name' => 'Tags', + 'type' => 'multipleSelects', + ), + ), ), ), ); } elseif ( 'POST' === $method ) { // Mock successful record creation for POST requests. return array( - 'id' => 'rec123456789', - 'fields' => $body['records'][0]['fields'], - 'createdTime' => '2023-01-01T00:00:00.000Z', + 'records' => array( + array( + 'id' => 'rec123456789', + 'fields' => $body['records'][0]['fields'], + 'createdTime' => '2023-01-01T00:00:00.000Z', + ), + ), + ); + } + } elseif ( preg_match( '#^/v0/(app\d+)/([^/]+)/uploadAttachment$#', $path, $matches ) ) { + if ( $method === 'POST' ) { + return array( + 'createdTime' => '2022-02-01T21:25:05.663Z', + 'fields' => array( + 'fld00000000000000' => array( + array( + 'filename' => 'sample.txt', + 'id' => 'att00000000000000', + 'size' => 11, + 'type' => 'text/plain', + 'url' => 'https://v5.airtableusercontent.com/v3/u/29/29/1716940800000/ffhiecnieIwxisnIBDSAln/foDeknw_G5CdkdPW1j-U0yUCX9YSaE1EJft3wvXb85pnTY1sKZdYeFvKpsM-fqOa6Bnu5MQVPA_ApINEUXL_E3SAZn6z01VN9Pn9SluhSy4NoakZGapcvl4tuN3jktO2Dt7Ck_gh4oMdsrcV8J-t_A/53m17XmDDHsNtIqzM1PQVnRKutK6damFgNNS5WCaTbI', + ), + ), + ), + 'id' => 'rec00000000000000', ); } } @@ -372,16 +421,49 @@ public function test_post_create_record() { ); $payload = array( - 'Name' => 'John Doe', - 'Email' => 'john.doe@example.com', + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com', + 'Score' => 99, + 'Tags' => array( 'A', 'B' ), + 'Active' => true, ); $response = $bridge->submit( $payload ); $this->assertFalse( is_wp_error( $response ) ); $this->assertArrayHasKey( 'data', $response ); - $this->assertEquals( 'rec123456789', $response['data']['id'] ); - $this->assertEquals( 'John Doe', $response['data']['fields']['Name'] ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + $this->assertEquals( 'John Doe', $response['data']['records'][0]['fields']['Name'] ); + } + + /** + * Test POST request to create a record with uploads. + */ + public function test_post_create_record_with_uploads() { + $bridge = new Airtable_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/v0/app123456789/Contacts', + 'method' => 'POST', + ) + ); + + $payload = array( + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com', + 'Score' => 99, + 'Tags' => array( 'A', 'B' ), + 'Active' => true, + 'Profile Picture' => 'file', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + $this->assertEquals( 'John Doe', $response['data']['records'][0]['fields']['Name'] ); } /** @@ -401,8 +483,10 @@ public function test_get_table_schema() { $this->assertFalse( is_wp_error( $response ) ); $this->assertArrayHasKey( 'data', $response ); - $this->assertArrayHasKey( 'fields', $response['data'] ); - $this->assertCount( 5, $response['data']['fields'] ); + $this->assertArrayHasKey( 'records', $response['data'] ); + $this->assertCount( 1, $response['data']['records'] ); + $this->assertArrayHasKey( 'fields', $response['data']['records'][0] ); + $this->assertCount( 5, $response['data']['records'][0]['fields'] ); } /** @@ -474,6 +558,7 @@ public function test_addon_get_endpoint_schema_post() { $this->assertContains( 'Active', $field_names ); $this->assertContains( 'Score', $field_names ); $this->assertContains( 'Tags', $field_names ); + $this->assertContains( 'Profile Picture', $field_names ); // Check field types. $schema_map = array(); @@ -486,6 +571,7 @@ public function test_addon_get_endpoint_schema_post() { $this->assertEquals( 'boolean', $schema_map['Active'] ); $this->assertEquals( 'number', $schema_map['Score'] ); $this->assertEquals( 'array', $schema_map['Tags'] ); + $this->assertEquals( 'file', $schema_map['Profile Picture'] ); } /** @@ -581,20 +667,6 @@ public function test_authorization_header_transformation() { * Test field filtering in schema - should exclude certain field types. */ public function test_field_filtering_in_schema() { - // Add a field that should be excluded. - $mock_fields = array( - array( - 'id' => 'fld_excluded', - 'name' => 'ExcludedField', - 'type' => 'aiText', // This type should be excluded. - ), - array( - 'id' => 'fld_included', - 'name' => 'IncludedField', - 'type' => 'singleLineText', // This type should be included. - ), - ); - $addon = Addon::addon( 'airtable' ); $schema = $addon->get_endpoint_schema( '/v0/app123456789/Contacts', @@ -606,6 +678,6 @@ public function test_field_filtering_in_schema() { $field_names = array_column( $schema, 'name' ); $this->assertContains( 'Name', $field_names ); $this->assertNotContains( 'Summary', $field_names ); - $this->assertCount( 5, $schema ); + $this->assertCount( 6, $schema ); } } diff --git a/tests/addons/test-grist.php b/tests/addons/test-grist.php new file mode 100644 index 00000000..7b76832b --- /dev/null +++ b/tests/addons/test-grist.php @@ -0,0 +1,687 @@ + self::CREDENTIAL_NAME, + 'schema' => 'Bearer', + 'access_token' => 'test-api-key', + 'expires_at' => time() + 3600, + ) + ), + ); + } + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => self::BACKEND_NAME, + 'base_url' => self::BACKEND_URL, + 'credential' => self::CREDENTIAL_NAME, + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'orgId', + 'value' => 'test', + ), + ), + ) + ), + ); + } + + /** + * HTTP requests interceptor. + * + * @param mixed $pre Initial pre hook value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public static function pre_http_request( $pre, $args, $url ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + $http = array( + 'code' => 200, + 'message' => 'OK', + ); + + $method = $args['method'] ?? 'GET'; + + // Parse URL to determine the endpoint being called. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + + // Parse the body to determine the method being called. + $body = array(); + if ( ! empty( $args['body'] ) ) { + if ( is_string( $args['body'] ) ) { + $body = json_decode( $args['body'], true ); + } else { + $body = $args['body']; + } + } + + // Return appropriate mock response based on endpoint. + if ( self::$mock_response ) { + $http = self::$mock_response['http'] ?? $http; + unset( self::$mock_response['http'] ); + $response_body = self::$mock_response; + + self::$mock_response = null; + } else { + $response_body = self::get_mock_response( $method, $path, $body ); + } + + return array( + 'response' => $http, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( $response_body ), + 'http_response' => null, + ); + } + + /** + * Get mock response based on API endpoint. + * + * @param string $method HTTP method. + * @param string $path API endpoint path. + * @param array $body Request body. + * + * @return array Mock response. + */ + private static function get_mock_response( $method, $path, $body ) { + // Organizations endpoint. + if ( '/api/orgs' === $path && 'GET' === $method ) { + return array( + 'orgs' => array( + array( + 'id' => 'org123456789', + 'name' => 'Test Organization', + ), + ), + ); + } + + // Workspaces endpoint. + if ( preg_match( '/^\/api\/orgs\/([^\/]+)\/workspaces$/', $path ) && 'GET' === $method ) { + return array( + array( + 'id' => 'ws123456789', + 'name' => 'Test Workspace', + 'docs' => array( + array( + 'id' => self::DOC_ID, + 'urlId' => self::DOC_ID, + 'name' => 'Test Document', + 'access' => 'owners', + ), + ), + ), + ); + } + + // Tables endpoint. + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables$/', $path ) && 'GET' === $method ) { + return array( + 'tables' => array( + array( + 'id' => self::TABLE_ID, + 'name' => 'Test Table', + ), + array( + 'id' => 'another-table', + 'name' => 'Another Table', + ), + ), + ); + } + + // Columns endpoint. + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables\/([^\/]+)\/columns$/', $path ) && 'GET' === $method ) { + return array( + 'columns' => array( + array( + 'id' => 'name', + 'fields' => array( + 'label' => 'Name', + 'type' => 'Text', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'email', + 'fields' => array( + 'label' => 'Email', + 'type' => 'Text', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'age', + 'fields' => array( + 'label' => 'Age', + 'type' => 'Int', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'active', + 'fields' => array( + 'label' => 'Active', + 'type' => 'Bool', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'tags', + 'fields' => array( + 'label' => 'Tags', + 'type' => 'ChoiceList', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => json_encode( + array( + 'choices' => array( 'A', 'B', 'C' ), + ) + ), + ), + ), + array( + 'id' => 'attachment', + 'fields' => array( + 'label' => 'Attachment', + 'type' => 'Attachments', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'formula_field', + 'fields' => array( + 'label' => 'Formula Field', + 'type' => 'Text', + 'isFormula' => true, + 'formula' => '=1+1', + 'widgetOptions' => '{}', + ), + ), + array( + 'id' => 'ref_field', + 'fields' => array( + 'label' => 'Reference Field', + 'type' => 'Ref:test-table-456', + 'isFormula' => false, + 'formula' => '', + 'widgetOptions' => '{}', + ), + ), + ), + ); + } + + // Records endpoint (POST). + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/tables\/([^\/]+)\/records$/', $path ) ) { + if ( 'POST' === $method ) { + return array( + 'records' => array( array( 'id' => 'rec123456789' ) ), + ); + } else { + return array( + 'records' => array( + array( + 'id' => 'rec123456789', + 'fields' => array( + 'email' => 'john.doe@example.coop', + 'name' => 'John Doe', + 'age' => 43, + 'tags' => array( 'L', 'A', 'B' ), + 'active' => true, + 'attachments' => array( 'L', 1 ), + ), + ), + ), + ); + } + } + + // Attachments endpoint (POST). + if ( preg_match( '/^\/api\/docs\/doc([^\/]+)\/attachments$/', $path ) && 'POST' === $method ) { + return array( 1 ); + } + + // Default empty response. + return array(); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + tests_add_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + tests_add_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + tests_add_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down test filters. + */ + public function tear_down() { + remove_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + remove_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + remove_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + + parent::tear_down(); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Grist_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables', + 'method' => 'GET', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertFalse( $bridge->is_valid ); + } + + /** + * Test POST request to create a record. + */ + public function test_post_create_record() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'tags' => array( 'A', 'B' ), + 'active' => true, + 'age' => 42, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + } + + /** + * Test POST request to create a record with uploads. + */ + public function test_post_create_record_with_upload() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'attachment' => 'file', + 'tags' => array( 'A', 'B' ), + 'active' => true, + 'age' => 42, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( 'rec123456789', $response['data']['records'][0]['id'] ); + } + + /** + * Test GET request to fetch table schema. + */ + public function test_get_table_schema() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit(); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'records', $response['data'] ); + $this->assertCount( 1, $response['data']['records'] ); + $this->assertArrayHasKey( 'fields', $response['data']['records'][0] ); + $this->assertCount( 6, $response['data']['records'][0]['fields'] ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'grist' ); + $response = $addon->ping( self::BACKEND_NAME ); + + $this->assertTrue( $response ); + } + + /** + * Test addon fetch method to get tables. + */ + public function test_addon_fetch_tables() { + $addon = Addon::addon( 'grist' ); + $response = $addon->fetch( '/api/orgs/{orgId}/tables', self::BACKEND_NAME ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'tables', $response['data'] ); + $this->assertCount( 2, $response['data']['tables'] ); + + // Check that tables have the expected structure. + $table = $response['data']['tables'][0]; + $this->assertArrayHasKey( 'org_id', $table ); + $this->assertArrayHasKey( 'doc_id', $table ); + $this->assertArrayHasKey( 'doc_name', $table ); + $this->assertArrayHasKey( 'label', $table ); + $this->assertArrayHasKey( 'id', $table ); + $this->assertArrayHasKey( 'endpoint', $table ); + $this->assertEquals( 'Test Document/' . self::TABLE_ID, $table['label'] ); + $this->assertEquals( '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', $table['endpoint'] ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'grist' ); + $endpoints = $addon->get_endpoints( self::BACKEND_NAME ); + + $this->assertIsArray( $endpoints ); + $this->assertCount( 2, $endpoints ); + $this->assertContains( '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', $endpoints ); + } + + /** + * Test addon get_endpoint_schema method for POST. + */ + public function test_addon_get_endpoint_schema_post() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'POST' + ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + // Check that schema contains expected fields. + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertContains( 'email', $field_names ); + $this->assertContains( 'active', $field_names ); + $this->assertContains( 'age', $field_names ); + $this->assertContains( 'tags', $field_names ); + $this->assertContains( 'attachment', $field_names ); + + // Check field types. + $schema_map = array(); + foreach ( $schema as $field ) { + $schema_map[ $field['name'] ] = $field['schema']['type']; + } + + $this->assertEquals( 'string', $schema_map['name'] ); + $this->assertEquals( 'string', $schema_map['email'] ); + $this->assertEquals( 'boolean', $schema_map['active'] ); + $this->assertEquals( 'array', $schema_map['tags'] ); + $this->assertEquals( 'file', $schema_map['attachment'] ); + $this->assertEquals( 'number', $schema_map['age'] ); + } + + /** + * Test addon get_endpoint_schema method for non-POST methods. + */ + public function test_addon_get_endpoint_schema_non_post() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'GET' + ); + + // Should return empty array for non-POST methods. + $this->assertIsArray( $schema ); + $this->assertEmpty( $schema ); + } + + /** + * Test error response handling. + */ + public function test_error_response_handling() { + self::$mock_response = array( + 'http' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'error' => 'Bad request: invalid API key', + ); + + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test authorization header transformation. + */ + public function test_authorization_header_transformation() { + $bridge = new Grist_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + 'method' => 'GET', + ) + ); + + $bridge->submit(); + + // Check that the request was made. + $this->assertNotNull( self::$request ); + + // Verify the Authorization header was transformed. + $headers = self::$request['args']['headers'] ?? array(); + $this->assertArrayHasKey( 'Authorization', $headers ); + $this->assertStringContainsString( 'Bearer', $headers['Authorization'] ); + $this->assertStringContainsString( 'test-api-key', $headers['Authorization'] ); + } + + /** + * Test field filtering in schema - should exclude certain field types. + */ + public function test_field_filtering_in_schema() { + $addon = Addon::addon( 'grist' ); + $schema = $addon->get_endpoint_schema( + '/api/docs/' . self::DOC_ID . '/tables/' . self::TABLE_ID . '/records', + self::BACKEND_NAME, + 'POST' + ); + + // Should only include the singleLineText field, not the aiText field. + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertNotContains( 'formula_field', $field_names ); + $this->assertNotContains( 'ref_field', $field_names ); + $this->assertCount( 6, $schema ); + } +}