From a8e7529f5121b2a68bc3708939c00fd842808502 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Sun, 16 Aug 2026 19:35:02 +0200 Subject: [PATCH] Import forms from Gravity Forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gravity keeps its forms in tables of its own — gf_form and gf_form_meta, with display_meta as JSON and the notifications and confirmations in columns beside it — so the importer reads the tables directly and works whether the plugin is active, deactivated or deleted. No GFAPI. Two conversions carry the most weight. Conditional logic is a straight translation: Gravity's flat rule list with an all/any mode is the same shape this plugin evaluates, so it maps rule for rule on fields, notifications and confirmations alike. And a List field becomes a real repeater — Gravity's columns are the repeater's sub-fields, so "add another row" keeps meaning exactly that. Products and options keep their prices ($25.00 and 10,00 € both parse), the total becomes a calculated total over them, name fields keep the parts Gravity's input numbering says are visible, field-addressed notifications keep their address, and non-submission events (payment completed, entry approved) are deliberately left behind rather than imported as mails that would fire at the wrong moment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp --- allterrain-forms.php | 1 + includes/importer-gravityforms.php | 672 ++++++++++++++++++ readme.txt | 15 +- tests/phpunit/tests/importersGravityforms.php | 405 +++++++++++ 4 files changed, 1086 insertions(+), 7 deletions(-) create mode 100644 includes/importer-gravityforms.php create mode 100644 tests/phpunit/tests/importersGravityforms.php diff --git a/allterrain-forms.php b/allterrain-forms.php index 37b778d..312d121 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -157,6 +157,7 @@ require_once ATF_DIR . 'includes/importers.php'; require_once ATF_DIR . 'includes/importer-cf7.php'; require_once ATF_DIR . 'includes/importer-wpforms.php'; +require_once ATF_DIR . 'includes/importer-gravityforms.php'; require_once ATF_DIR . 'includes/rest.php'; require_once ATF_DIR . 'includes/preview.php'; require_once ATF_DIR . 'includes/shortcode.php'; diff --git a/includes/importer-gravityforms.php b/includes/importer-gravityforms.php new file mode 100644 index 0000000..32a0570 --- /dev/null +++ b/includes/importer-gravityforms.php @@ -0,0 +1,672 @@ + definition. + * @return array[] + */ +function atf_register_gravityforms_importer( $importers ) { + $importers['gravityforms'] = array( + 'label' => __( 'Gravity Forms', 'allterrain-forms' ), + 'available' => 'atf_gf_available', + 'forms' => 'atf_gf_forms', + 'import' => 'atf_gf_import', + ); + + return $importers; +} +add_filter( 'atf_importers', 'atf_register_gravityforms_importer' ); + +/** + * Whether Gravity Forms' tables exist on this site. + * + * @since 0.2.0 + * + * @return bool + */ +function atf_gf_available() { + global $wpdb; + + $table = $wpdb->prefix . 'gf_form'; + + // The table's existence is the availability test -- an empty table means + // the section renders with "No forms found", which is more honest than + // hiding the source somebody expected to see. + return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- There is no API for another plugin's table, and a SHOW TABLES result is not worth caching. +} + +/** + * The Gravity Forms forms on this site, id => title. + * + * @since 0.2.0 + * + * @return array + */ +function atf_gf_forms() { + global $wpdb; + + if ( ! atf_gf_available() ) { + return array(); + } + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Another plugin's table; read once per Import page view. + "SELECT id, title FROM {$wpdb->prefix}gf_form WHERE is_trash = 0 ORDER BY title ASC LIMIT 200" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the prefix is interpolated. + ); + + $forms = array(); + + foreach ( (array) $rows as $row ) { + $forms[ (string) $row->id ] = '' !== $row->title ? $row->title : __( '(untitled)', 'allterrain-forms' ); + } + + return $forms; +} + +/** + * Imports one Gravity Forms form. + * + * @since 0.2.0 + * + * @param string $source_id The Gravity Forms form id. + * @return int|WP_Error The new form's id. + */ +function atf_gf_import( $source_id ) { + global $wpdb; + + $source_id = absint( $source_id ); + + if ( ! $source_id || ! atf_gf_available() ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Another plugin's table; read once per import. + $wpdb->prepare( + "SELECT f.title, m.display_meta, m.notifications, m.confirmations + FROM {$wpdb->prefix}gf_form f + LEFT JOIN {$wpdb->prefix}gf_form_meta m ON m.form_id = f.id + WHERE f.id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the prefix is interpolated. + $source_id + ) + ); + + if ( ! $row ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + $display = json_decode( (string) $row->display_meta, true ); + + if ( ! is_array( $display ) ) { + return new WP_Error( 'atf_import_unreadable', __( 'That form could not be read.', 'allterrain-forms' ) ); + } + + $schema = atf_gf_convert( + $display, + json_decode( (string) $row->notifications, true ), + json_decode( (string) $row->confirmations, true ) + ); + + $title = '' !== (string) $row->title ? (string) $row->title : ( isset( $display['title'] ) ? (string) $display['title'] : '' ); + + return atf_create_imported_form( $title, $schema, 'gravityforms', (string) $source_id ); +} + +/** + * Converts one Gravity Forms form into a schema. + * + * Pure — no database, no capability checks — so the conversion rules can be + * tested against fixture arrays without creating tables. + * + * @since 0.2.0 + * + * @param array $display The decoded `display_meta`. + * @param array|null $notifications The decoded notifications column. + * @param array|null $confirmations The decoded confirmations column. + * @return array A raw schema, ready for `atf_normalize_schema()`. + */ +function atf_gf_convert( $display, $notifications = null, $confirmations = null ) { + $source_fields = isset( $display['fields'] ) && is_array( $display['fields'] ) ? $display['fields'] : array(); + + $fields = array(); + $map = array(); + $products = array(); + $next = 1; + + // First pass mints ids, so conditional logic can reference a field that + // appears later in the form. + foreach ( $source_fields as $source ) { + if ( is_array( $source ) && isset( $source['id'] ) ) { + $map[ (string) $source['id'] ] = 'f' . $next; + ++$next; + } + } + + foreach ( $source_fields as $source ) { + if ( ! is_array( $source ) || ! isset( $source['id'] ) ) { + continue; + } + + $field = atf_gf_field( $source, $map[ (string) $source['id'] ], $map ); + + if ( ! $field ) { + unset( $map[ (string) $source['id'] ] ); + continue; + } + + $type = isset( $source['type'] ) ? strtolower( (string) $source['type'] ) : ''; + + if ( in_array( $type, array( 'product', 'option' ), true ) && 'total' !== $field['type'] ) { + $products[] = $field['id']; + } + + // The total sums every product and option before it — the same number + // Gravity's own running total showed. + if ( 'total' === $field['type'] && $products ) { + $field['formula'] = '{' . implode( '} + {', $products ) . '}'; + } + + $fields[] = $field; + } + + $schema = array( + 'fields' => $fields, + 'settings' => array(), + 'notifications' => array(), + 'confirmations' => array(), + ); + + if ( isset( $display['button']['text'] ) && '' !== $display['button']['text'] ) { + $schema['settings']['submitLabel'] = (string) $display['button']['text']; + } + + foreach ( (array) $notifications as $notification ) { + if ( ! is_array( $notification ) ) { + continue; + } + + // Admin notifications fire on submission. Anything wired to another + // event — payment completed, entry approved — has no equivalent here + // and importing it as a submission mail would send it at the wrong + // moment entirely. + if ( isset( $notification['event'] ) && 'form_submission' !== $notification['event'] ) { + continue; + } + + $to = isset( $notification['to'] ) ? (string) $notification['to'] : ''; + + // `toType: field` stores a bare field id in `to`; as text it would + // address the mail to the digit three. + if ( isset( $notification['toType'] ) && 'field' === $notification['toType'] && isset( $map[ $to ] ) ) { + $to = '{field:' . $map[ $to ] . '}'; + } else { + $to = atf_gf_replace_tags( $to, $map ); + } + + $schema['notifications'][] = array( + 'name' => isset( $notification['name'] ) && '' !== $notification['name'] ? (string) $notification['name'] : __( 'Notification', 'allterrain-forms' ), + 'enabled' => empty( $notification['isActive'] ) && isset( $notification['isActive'] ) ? false : true, + 'to' => $to, + 'subject' => atf_gf_replace_tags( isset( $notification['subject'] ) ? (string) $notification['subject'] : '', $map ), + 'message' => atf_gf_replace_tags( isset( $notification['message'] ) ? (string) $notification['message'] : '', $map ), + 'fromName' => atf_gf_replace_tags( isset( $notification['fromName'] ) ? (string) $notification['fromName'] : '', $map ), + 'replyTo' => atf_gf_replace_tags( isset( $notification['replyTo'] ) ? (string) $notification['replyTo'] : '', $map ), + 'logic' => atf_gf_logic( isset( $notification['conditionalLogic'] ) ? $notification['conditionalLogic'] : null, $map ), + ); + } + + foreach ( (array) $confirmations as $confirmation ) { + if ( ! is_array( $confirmation ) ) { + continue; + } + + $type = isset( $confirmation['type'] ) ? (string) $confirmation['type'] : 'message'; + + $schema['confirmations'][] = array( + 'name' => isset( $confirmation['name'] ) && '' !== $confirmation['name'] ? (string) $confirmation['name'] : __( 'Confirmation', 'allterrain-forms' ), + 'type' => in_array( $type, array( 'message', 'redirect', 'page' ), true ) ? $type : 'message', + 'message' => atf_gf_replace_tags( isset( $confirmation['message'] ) ? (string) $confirmation['message'] : '', $map ), + 'url' => isset( $confirmation['url'] ) ? (string) $confirmation['url'] : '', + 'pageId' => isset( $confirmation['pageId'] ) ? absint( $confirmation['pageId'] ) : 0, + 'query' => atf_gf_replace_tags( isset( $confirmation['queryString'] ) ? (string) $confirmation['queryString'] : '', $map ), + 'logic' => atf_gf_logic( isset( $confirmation['conditionalLogic'] ) ? $confirmation['conditionalLogic'] : null, $map ), + ); + } + + return $schema; +} + +/** + * Converts one Gravity Forms field. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @param string $id The id the new field will take. + * @param array $map Gravity field id => new field id, for logic rules. + * @return array|null The field, or null when the source has no equivalent. + */ +function atf_gf_field( $source, $id, $map ) { + $type = isset( $source['type'] ) ? strtolower( (string) $source['type'] ) : ''; + + // A post field is its input type wearing a publishing hat; the answer + // shape is what matters here. + $post_types = array( + 'post_title' => 'text', + 'post_content' => 'textarea', + 'post_excerpt' => 'textarea', + ); + + if ( isset( $post_types[ $type ] ) ) { + $type = $post_types[ $type ]; + } + + $simple = array( + 'text' => 'text', + 'textarea' => 'textarea', + 'email' => 'email', + 'phone' => 'tel', + 'website' => 'url', + 'number' => 'number', + 'hidden' => 'hidden', + 'date' => 'date', + 'time' => 'time', + 'fileupload' => 'file', + 'signature' => 'signature', + 'page' => 'page_break', + 'section' => 'heading', + 'html' => 'html', + 'total' => 'total', + 'quantity' => 'number', + ); + + $field = array( + 'id' => $id, + 'label' => isset( $source['label'] ) ? (string) $source['label'] : '', + 'required' => ! empty( $source['isRequired'] ), + 'logic' => atf_gf_logic( isset( $source['conditionalLogic'] ) ? $source['conditionalLogic'] : null, $map ), + ); + + if ( isset( $source['description'] ) && '' !== $source['description'] ) { + $field['hint'] = (string) $source['description']; + } + + if ( isset( $source['placeholder'] ) && '' !== $source['placeholder'] ) { + $field['placeholder'] = (string) $source['placeholder']; + } + + if ( isset( $source['defaultValue'] ) && is_scalar( $source['defaultValue'] ) && '' !== $source['defaultValue'] ) { + $field['default'] = (string) $source['defaultValue']; + } + + if ( isset( $simple[ $type ] ) ) { + $field['type'] = $simple[ $type ]; + + if ( 'number' === $type ) { + if ( isset( $source['rangeMin'] ) && '' !== $source['rangeMin'] ) { + $field['min'] = (string) $source['rangeMin']; + } + + if ( isset( $source['rangeMax'] ) && '' !== $source['rangeMax'] ) { + $field['max'] = (string) $source['rangeMax']; + } + } + + if ( 'html' === $type ) { + $field['content'] = isset( $source['content'] ) ? (string) $source['content'] : ''; + } + + if ( 'fileupload' === $type && ! empty( $source['allowedExtensions'] ) ) { + $field['filetypes'] = array_values( + array_filter( array_map( 'trim', explode( ',', strtolower( (string) $source['allowedExtensions'] ) ) ) ) + ); + } + + return $field; + } + + switch ( $type ) { + case 'select': + case 'multiselect': + case 'radio': + case 'checkbox': + $types = array( + 'select' => 'select', + 'multiselect' => 'multiselect', + 'radio' => 'radio', + 'checkbox' => 'checkboxes', + ); + $field['type'] = $types[ $type ]; + + $converted = atf_gf_choices( $source ); + + $field['choices'] = $converted['choices']; + + if ( $converted['other'] ) { + $field['other'] = true; + } + + return $field; + + case 'name': + $field['type'] = 'name'; + $field['parts'] = atf_gf_name_parts( $source ); + + return $field; + + case 'address': + $field['type'] = 'address'; + + return $field; + + case 'consent': + $field['type'] = 'consent'; + + if ( isset( $source['checkboxLabel'] ) && '' !== $source['checkboxLabel'] ) { + $field['consentText'] = (string) $source['checkboxLabel']; + } + + return $field; + + case 'list': + // Gravity's List is rows the visitor adds — a repeater. Its + // columns are the sub-fields; a single-column list becomes one + // sub-field wearing the list's own label. + $field['type'] = 'repeater'; + + $columns = array(); + + if ( ! empty( $source['enableColumns'] ) && isset( $source['choices'] ) && is_array( $source['choices'] ) ) { + foreach ( $source['choices'] as $column ) { + if ( is_array( $column ) && isset( $column['text'] ) && '' !== $column['text'] ) { + $columns[] = (string) $column['text']; + } + } + } + + if ( ! $columns ) { + $columns = array( '' !== $field['label'] ? $field['label'] : __( 'Item', 'allterrain-forms' ) ); + } + + $subs = array(); + + foreach ( $columns as $index => $column ) { + $subs[] = array( + 'id' => 's' . ( $index + 1 ), + 'type' => 'text', + 'label' => $column, + ); + } + + $field['fields'] = $subs; + + return $field; + + case 'product': + $input_type = isset( $source['inputType'] ) ? strtolower( (string) $source['inputType'] ) : 'singleproduct'; + + if ( in_array( $input_type, array( 'select', 'radio' ), true ) ) { + $field['type'] = 'select' === $input_type ? 'select' : 'radio'; + $field['choices'] = atf_gf_choices( $source )['choices']; + + return $field; + } + + // A fixed-price product is a number holding its price, so the + // imported total can still sum it. The visitor-facing behaviour + // differs from Gravity's quantity pairing — that is the honest + // limit of the mapping, reviewable in the builder. + $field['type'] = 'number'; + + if ( isset( $source['basePrice'] ) && '' !== $source['basePrice'] ) { + $field['default'] = (string) atf_gf_price( $source['basePrice'] ); + } + + return $field; + + case 'option': + $field['type'] = 'checkboxes'; + $field['choices'] = atf_gf_choices( $source )['choices']; + + return $field; + + case 'captcha': + case 'honeypot': + return null; + } + + // An unrecognised add-on field becomes a visible text field rather than + // vanishing — reviewable in the builder, where dropped data would not be. + $field['type'] = 'text'; + + return $field; +} + +/** + * Converts a Gravity choices list. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @return array { choices: array[], other: bool } + */ +function atf_gf_choices( $source ) { + $raw = isset( $source['choices'] ) && is_array( $source['choices'] ) ? $source['choices'] : array(); + $priced = in_array( strtolower( isset( $source['type'] ) ? (string) $source['type'] : '' ), array( 'product', 'option' ), true ) + || ! empty( $source['enablePrice'] ); + $choices = array(); + + foreach ( $raw as $choice ) { + if ( ! is_array( $choice ) ) { + continue; + } + + $label = isset( $choice['text'] ) ? (string) $choice['text'] : ''; + $value = isset( $choice['value'] ) ? (string) $choice['value'] : ''; + + if ( '' === $label && '' === $value ) { + continue; + } + + $converted = array( + 'label' => '' !== $label ? $label : $value, + 'value' => '' !== $value ? $value : $label, + ); + + if ( $priced && isset( $choice['price'] ) && '' !== $choice['price'] ) { + $converted['price'] = atf_gf_price( $choice['price'] ); + } + + if ( ! empty( $choice['isSelected'] ) ) { + $converted['selected'] = true; + } + + $choices[] = $converted; + } + + return array( + 'choices' => $choices, + 'other' => ! empty( $source['enableOtherChoice'] ), + ); +} + +/** + * A Gravity price string as a number. + * + * Gravity stores prices with their currency formatting — `$25.00`, `25,00 €` — + * and a formula can only add numbers. + * + * @since 0.2.0 + * + * @param mixed $price The stored price. + * @return float + */ +function atf_gf_price( $price ) { + $price = preg_replace( '/[^0-9.,\-]/', '', (string) $price ); + + // A comma-decimal price with no dot reads as European formatting. + if ( false !== strpos( $price, ',' ) && false === strpos( $price, '.' ) ) { + $price = str_replace( ',', '.', $price ); + } else { + $price = str_replace( ',', '', $price ); + } + + return (float) $price; +} + +/** + * The name parts a Gravity name field shows. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @return string[] + */ +function atf_gf_name_parts( $source ) { + $known = array( + '2' => 'prefix', + '3' => 'first', + '4' => 'middle', + '6' => 'last', + '8' => 'suffix', + ); + + $parts = array(); + + if ( isset( $source['inputs'] ) && is_array( $source['inputs'] ) ) { + foreach ( $source['inputs'] as $input ) { + if ( ! is_array( $input ) || ! empty( $input['isHidden'] ) || ! isset( $input['id'] ) ) { + continue; + } + + // Gravity numbers name inputs `{field}.{part}` — 3 is first, 6 is + // last — and that sub-number is stable across every install. + $sub = substr( (string) $input['id'], strpos( (string) $input['id'], '.' ) + 1 ); + + if ( isset( $known[ $sub ] ) ) { + $parts[] = $known[ $sub ]; + } + } + } + + return $parts ? $parts : array( 'first', 'last' ); +} + +/** + * Converts Gravity conditional logic. + * + * The one importer where this is a straight translation: Gravity's logic is a + * flat rule list with an all/any mode and a show/hide action — the same shape + * this plugin evaluates. + * + * @since 0.2.0 + * + * @param mixed $logic The `conditionalLogic` block. + * @param array $map Gravity field id => new field id. + * @return array The logic block, disabled when there was none. + */ +function atf_gf_logic( $logic, $map ) { + if ( ! is_array( $logic ) || empty( $logic['rules'] ) || ! is_array( $logic['rules'] ) ) { + return array( 'enabled' => false ); + } + + $operators = array( + 'is' => 'is', + 'isnot' => 'is_not', + 'contains' => 'contains', + 'starts_with' => 'starts_with', + 'ends_with' => 'ends_with', + '>' => 'greater', + '<' => 'less', + ); + + $rules = array(); + + foreach ( $logic['rules'] as $rule ) { + if ( ! is_array( $rule ) || ! isset( $rule['fieldId'] ) ) { + continue; + } + + // A rule against a composite input (`1.3`) is a rule against the + // composite field. + $field_id = (string) $rule['fieldId']; + $field_id = false !== strpos( $field_id, '.' ) ? substr( $field_id, 0, strpos( $field_id, '.' ) ) : $field_id; + + if ( ! isset( $map[ $field_id ] ) ) { + continue; + } + + $operator = isset( $rule['operator'] ) ? strtolower( (string) $rule['operator'] ) : 'is'; + + $rules[] = array( + 'field' => $map[ $field_id ], + 'operator' => isset( $operators[ $operator ] ) ? $operators[ $operator ] : 'is', + 'value' => isset( $rule['value'] ) && is_scalar( $rule['value'] ) ? (string) $rule['value'] : '', + ); + } + + if ( ! $rules ) { + return array( 'enabled' => false ); + } + + return array( + 'enabled' => true, + 'action' => isset( $logic['actionType'] ) && 'hide' === $logic['actionType'] ? 'hide' : 'show', + 'match' => isset( $logic['logicType'] ) && 'any' === $logic['logicType'] ? 'any' : 'all', + 'rules' => $rules, + ); +} + +/** + * Rewrites Gravity merge tags onto this plugin's. + * + * `{Label:3}` becomes `{field:…}` — the label half is display sugar, the + * number is the reference. Specials with equivalents are translated; a tag + * with no equivalent is left visible rather than silently deleted. + * + * @since 0.2.0 + * + * @param string $text The template text. + * @param array $map Gravity field id => new field id. + * @return string + */ +function atf_gf_replace_tags( $text, $map ) { + $specials = array( + '{all_fields}' => '{all_fields}', + '{admin_email}' => '{admin_email}', + '{form_title}' => '{form}', + '{ip}' => '{ip}', + '{embed_url}' => '{referrer}', + '{date_mdy}' => '{date}', + '{date_dmy}' => '{date}', + ); + + $text = strtr( (string) $text, $specials ); + + return preg_replace_callback( + '/\{[^{}:]*:(\d+)(?:\.\d+)?(?::[^{}]*)?\}/', + static function ( $found ) use ( $map ) { + return isset( $map[ $found[1] ] ) ? '{field:' . $map[ $found[1] ] . '}' : $found[0]; + }, + $text + ); +} diff --git a/readme.txt b/readme.txt index 8846ef2..19de8a2 100644 --- a/readme.txt +++ b/readme.txt @@ -97,9 +97,10 @@ form: real fields, the notification emails rewritten onto your own questions, the thank-you message carried over. The original is never touched, so importing is safe to try and safe to repeat. -Contact Form 7 and WPForms are supported today — WPForms imports include -conditional logic, payment fields with their prices, and a still-calculating -order total. Gravity Forms is next. +Contact Form 7, WPForms and Gravity Forms are all supported — imports carry +conditional logic across, payment and product fields keep their prices, an +order total keeps calculating, and a Gravity Forms List becomes a real +repeater. = Anti-spam that does not charge your visitors = @@ -179,10 +180,10 @@ uninstalling should do — export your entries to CSV first if you want them. = Can I import my forms from another form plugin? = -Yes. **Forms → Import** lists every form it can find from Contact Form 7 and -WPForms — including forms whose plugin has already been deactivated, because -the data outlives the plugin. Each import creates a new AllTerrain form and -leaves the original untouched. A Gravity Forms importer is on the way. +Yes. **Forms → Import** lists every form it can find from Contact Form 7, +WPForms and Gravity Forms — including forms whose plugin has already been +deactivated, because the data outlives the plugin. Each import creates a new +AllTerrain form and leaves the original untouched. = Can I move a form to another site? = diff --git a/tests/phpunit/tests/importersGravityforms.php b/tests/phpunit/tests/importersGravityforms.php new file mode 100644 index 0000000..afc71e6 --- /dev/null +++ b/tests/phpunit/tests/importersGravityforms.php @@ -0,0 +1,405 @@ + 'Contact us', + 'button' => array( + 'type' => 'text', + 'text' => 'Get in touch', + ), + 'fields' => array( + array( + 'type' => 'name', + 'id' => 1, + 'label' => 'Name', + 'isRequired' => true, + 'inputs' => array( + array( + 'id' => '1.3', + 'label' => 'First', + ), + array( + 'id' => '1.6', + 'label' => 'Last', + ), + ), + ), + array( + 'type' => 'email', + 'id' => 2, + 'label' => 'Email', + 'isRequired' => true, + ), + array( + 'type' => 'radio', + 'id' => 3, + 'label' => 'Topic', + 'choices' => array( + array( + 'text' => 'Support', + 'value' => 'Support', + ), + array( + 'text' => 'Sales', + 'value' => 'Sales', + ), + ), + 'enableOtherChoice' => true, + ), + array( + 'type' => 'textarea', + 'id' => 4, + 'label' => 'Message', + 'conditionalLogic' => array( + 'actionType' => 'show', + 'logicType' => 'any', + 'rules' => array( + array( + 'fieldId' => '3', + 'operator' => 'is', + 'value' => 'Support', + ), + array( + 'fieldId' => '3', + 'operator' => 'is', + 'value' => 'Sales', + ), + ), + ), + ), + array( + 'type' => 'consent', + 'id' => 5, + 'label' => 'Consent', + 'isRequired' => true, + 'checkboxLabel' => 'I agree to the privacy policy.', + ), + ), + ); + } + + /** + * The contact form converts field for field. + * + * @covers ::atf_gf_convert + * @covers ::atf_gf_field + */ + public function test_contact_display_meta_converts() { + $schema = atf_normalize_schema( atf_gf_convert( $this->contact_display_meta() ) ); + + $this->assertSame( array( 'name', 'email', 'radio', 'textarea', 'consent' ), wp_list_pluck( $schema['fields'], 'type' ) ); + $this->assertSame( array( 'first', 'last' ), $schema['fields'][0]['parts'] ); + $this->assertTrue( $schema['fields'][0]['required'] ); + $this->assertTrue( $schema['fields'][2]['other'], 'The "other" choice comes across.' ); + $this->assertSame( 'I agree to the privacy policy.', $schema['fields'][4]['consentText'] ); + $this->assertSame( 'Get in touch', $schema['settings']['submitLabel'] ); + + $logic = $schema['fields'][3]['logic']; + + $this->assertTrue( $logic['enabled'] ); + $this->assertSame( 'any', $logic['match'] ); + $this->assertSame( array( 'f3', 'f3' ), wp_list_pluck( $logic['rules'], 'field' ) ); + } + + /** + * Name parts follow Gravity's input numbering, hidden parts excluded. + * + * @covers ::atf_gf_name_parts + */ + public function test_name_parts() { + $parts = atf_gf_name_parts( + array( + 'inputs' => array( + array( + 'id' => '1.2', + 'isHidden' => true, + ), + array( 'id' => '1.3' ), + array( 'id' => '1.4' ), + array( 'id' => '1.6' ), + array( + 'id' => '1.8', + 'isHidden' => false, + ), + ), + ) + ); + + $this->assertSame( array( 'first', 'middle', 'last', 'suffix' ), $parts ); + } + + /** + * A List field becomes a repeater whose sub-fields are the columns. + * + * @covers ::atf_gf_field + */ + public function test_list_becomes_a_repeater() { + $fields = atf_gf_convert( + array( + 'fields' => array( + array( + 'type' => 'list', + 'id' => 1, + 'label' => 'Guests', + 'enableColumns' => true, + 'choices' => array( + array( 'text' => 'Guest name' ), + array( 'text' => 'Dietary needs' ), + ), + ), + array( + 'type' => 'list', + 'id' => 2, + 'label' => 'Websites', + ), + ), + ) + )['fields']; + + $this->assertSame( 'repeater', $fields[0]['type'] ); + $this->assertSame( array( 'Guest name', 'Dietary needs' ), wp_list_pluck( $fields[0]['fields'], 'label' ) ); + + // A single-column list keeps its own label as the one sub-field. + $this->assertCount( 1, $fields[1]['fields'] ); + $this->assertSame( 'Websites', $fields[1]['fields'][0]['label'] ); + } + + /** + * Products keep their prices and the total keeps calculating. + * + * @covers ::atf_gf_field + * @covers ::atf_gf_choices + * @covers ::atf_gf_price + */ + public function test_products_and_total() { + $schema = atf_gf_convert( + array( + 'fields' => array( + array( + 'type' => 'product', + 'id' => 1, + 'label' => 'Ticket', + 'inputType' => 'radio', + 'choices' => array( + array( + 'text' => 'Standard', + 'value' => 'Standard', + 'price' => '$25.00', + ), + array( + 'text' => 'VIP', + 'value' => 'VIP', + 'price' => '$100.00', + ), + ), + ), + array( + 'type' => 'option', + 'id' => 2, + 'label' => 'Extras', + 'choices' => array( + array( + 'text' => 'Parking', + 'value' => 'Parking', + 'price' => '10,00 €', + ), + ), + ), + array( + 'type' => 'total', + 'id' => 3, + 'label' => 'Total', + ), + ), + ) + ); + + $fields = $schema['fields']; + + $this->assertSame( 'radio', $fields[0]['type'] ); + $this->assertSame( 25.0, $fields[0]['choices'][0]['price'] ); + $this->assertSame( 100.0, $fields[0]['choices'][1]['price'] ); + + $this->assertSame( 'checkboxes', $fields[1]['type'] ); + $this->assertSame( 10.0, $fields[1]['choices'][0]['price'] ); + + $this->assertSame( '{f1} + {f2}', $fields[2]['formula'] ); + + $values = atf_apply_calculations( + atf_normalize_schema( $schema ), + array( + 'f1' => 'VIP', + 'f2' => array( 'Parking' ), + ) + ); + + $this->assertSame( 110.0, (float) $values['f3'] ); + } + + /** + * Notifications and confirmations convert, merge tags included. + * + * @covers ::atf_gf_convert + * @covers ::atf_gf_replace_tags + */ + public function test_notifications_and_confirmations() { + $schema = atf_normalize_schema( + atf_gf_convert( + $this->contact_display_meta(), + array( + array( + 'name' => 'Admin Notification', + 'event' => 'form_submission', + 'to' => '{admin_email}', + 'subject' => 'New submission from {Name (First):1.3}', + 'message' => '{all_fields} — sent from {embed_url}', + 'replyTo' => '{Email:2}', + ), + array( + 'name' => 'Payment done', + 'event' => 'complete_payment', + 'to' => 'x@y.z', + ), + array( + 'name' => 'To the visitor', + 'event' => 'form_submission', + 'to' => '2', + 'toType' => 'field', + ), + ), + array( + array( + 'name' => 'Default Confirmation', + 'type' => 'message', + 'message' => 'Thanks {Name (First):1.3}!', + ), + array( + 'name' => 'Send them on', + 'type' => 'redirect', + 'url' => 'https://example.com/thanks', + 'queryString' => 'topic={Topic:3}', + ), + ) + ) + ); + + $this->assertCount( 2, $schema['notifications'], 'The payment-event notification stays behind.' ); + + $admin = $schema['notifications'][0]; + + $this->assertSame( '{admin_email}', $admin['to'] ); + $this->assertSame( 'New submission from {field:f1}', $admin['subject'] ); + $this->assertStringContainsString( '{all_fields}', $admin['message'] ); + $this->assertStringContainsString( '{referrer}', $admin['message'] ); + $this->assertSame( '{field:f2}', $admin['replyTo'] ); + + $this->assertSame( '{field:f2}', $schema['notifications'][1]['to'], 'A field-addressed mail keeps its address.' ); + + $this->assertSame( 'Thanks {field:f1}!', $schema['confirmations'][0]['message'] ); + $this->assertSame( 'redirect', $schema['confirmations'][1]['type'] ); + $this->assertSame( 'topic={field:f3}', $schema['confirmations'][1]['query'] ); + } + + /** + * The whole trip, through the real tables. + * + * @covers ::atf_gf_import + * @covers ::atf_gf_forms + * @covers ::atf_gf_available + */ + public function test_gravityforms_import_end_to_end() { + global $wpdb; + + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + // Without the tables the importer reports itself unavailable. + $this->assertFalse( atf_gf_available() ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery -- Creating another plugin's tables is the fixture. + $wpdb->query( "CREATE TABLE {$wpdb->prefix}gf_form ( id mediumint(8) unsigned NOT NULL auto_increment, title varchar(150) NOT NULL, is_trash tinyint(1) NOT NULL default 0, PRIMARY KEY (id) )" ); + $wpdb->query( "CREATE TABLE {$wpdb->prefix}gf_form_meta ( form_id mediumint(8) unsigned NOT NULL, display_meta longtext, notifications longtext, confirmations longtext, PRIMARY KEY (form_id) )" ); + + $wpdb->insert( + $wpdb->prefix . 'gf_form', + array( + 'title' => 'Contact us', + 'is_trash' => 0, + ) + ); + $form_id = (int) $wpdb->insert_id; + + $wpdb->insert( + $wpdb->prefix . 'gf_form_meta', + array( + 'form_id' => $form_id, + 'display_meta' => wp_json_encode( $this->contact_display_meta() ), + 'notifications' => wp_json_encode( + array( + array( + 'name' => 'Admin Notification', + 'event' => 'form_submission', + 'to' => '{admin_email}', + 'subject' => 'New submission', + 'message' => '{all_fields}', + ), + ) + ), + 'confirmations' => wp_json_encode( + array( + array( + 'name' => 'Default Confirmation', + 'type' => 'message', + 'message' => 'Thanks!', + ), + ) + ), + ) + ); + + $this->assertTrue( atf_gf_available() ); + $this->assertSame( array( (string) $form_id => 'Contact us' ), atf_gf_forms() ); + + $new_id = atf_import_source_form( 'gravityforms', (string) $form_id ); + + $this->assertIsInt( $new_id ); + $this->assertSame( 'Contact us', get_post( $new_id )->post_title ); + + $schema = atf_get_form_schema( $new_id ); + + $this->assertCount( 5, $schema['fields'] ); + $this->assertSame( '{admin_email}', $schema['notifications'][0]['to'] ); + $this->assertSame( 'Thanks!', $schema['confirmations'][0]['message'] ); + + // The source tables are untouched. + $this->assertSame( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}gf_form WHERE id = %d", $form_id ) ) ); + + $wpdb->query( "DROP TABLE {$wpdb->prefix}gf_form" ); + $wpdb->query( "DROP TABLE {$wpdb->prefix}gf_form_meta" ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery + } +}