diff --git a/allterrain-forms.php b/allterrain-forms.php index 531f3f7..37b778d 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -156,6 +156,7 @@ require_once ATF_DIR . 'includes/templates.php'; 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/rest.php'; require_once ATF_DIR . 'includes/preview.php'; require_once ATF_DIR . 'includes/shortcode.php'; diff --git a/includes/importer-wpforms.php b/includes/importer-wpforms.php new file mode 100644 index 0000000..4e29e1c --- /dev/null +++ b/includes/importer-wpforms.php @@ -0,0 +1,636 @@ + definition. + * @return array[] + */ +function atf_register_wpforms_importer( $importers ) { + $importers['wpforms'] = array( + 'label' => __( 'WPForms', 'allterrain-forms' ), + 'available' => 'atf_wpforms_available', + 'forms' => 'atf_wpforms_forms', + 'import' => 'atf_wpforms_import', + ); + + return $importers; +} +add_filter( 'atf_importers', 'atf_register_wpforms_importer' ); + +/** + * Whether any WPForms forms exist on this site. + * + * @since 0.2.0 + * + * @return bool + */ +function atf_wpforms_available() { + return (bool) atf_wpforms_forms(); +} + +/** + * The WPForms forms on this site, id => title. + * + * @since 0.2.0 + * + * @return array + */ +function atf_wpforms_forms() { + $posts = get_posts( + array( + 'post_type' => 'wpforms', + 'post_status' => 'any', + // A hard bound, not a page size — see the CF7 importer for why. + 'posts_per_page' => 200, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $forms = array(); + + foreach ( $posts as $post ) { + $forms[ (string) $post->ID ] = '' !== $post->post_title ? $post->post_title : __( '(untitled)', 'allterrain-forms' ); + } + + return $forms; +} + +/** + * Imports one WPForms form. + * + * @since 0.2.0 + * + * @param string $source_id The WPForms post id. + * @return int|WP_Error The new form's id. + */ +function atf_wpforms_import( $source_id ) { + $post = get_post( absint( $source_id ) ); + + if ( ! $post || 'wpforms' !== $post->post_type ) { + return new WP_Error( 'atf_import_missing', __( 'That form no longer exists.', 'allterrain-forms' ) ); + } + + $data = json_decode( (string) $post->post_content, true ); + + if ( ! is_array( $data ) ) { + return new WP_Error( 'atf_import_unreadable', __( 'That form could not be read.', 'allterrain-forms' ) ); + } + + $schema = atf_wpforms_convert( $data ); + $title = '' !== $post->post_title + ? $post->post_title + : ( isset( $data['settings']['form_title'] ) ? (string) $data['settings']['form_title'] : '' ); + + return atf_create_imported_form( $title, $schema, 'wpforms', (string) $post->ID ); +} + +/** + * Converts one decoded WPForms document into a schema. + * + * Pure — no database, no capability checks — so the conversion rules can be + * tested against fixture JSON without inserting posts. + * + * @since 0.2.0 + * + * @param array $data The decoded `post_content`. + * @return array A raw schema, ready for `atf_normalize_schema()`. + */ +function atf_wpforms_convert( $data ) { + $source_fields = isset( $data['fields'] ) && is_array( $data['fields'] ) ? $data['fields'] : array(); + $settings = isset( $data['settings'] ) && is_array( $data['settings'] ) ? $data['settings'] : array(); + + $fields = array(); + $map = array(); + $payments = array(); + $next = 1; + + // First pass mints ids, so a field's 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 + count( $map ) ); + } + } + + foreach ( $source_fields as $source ) { + if ( ! is_array( $source ) || ! isset( $source['id'] ) ) { + continue; + } + + $field = atf_wpforms_field( $source, $map[ (string) $source['id'] ], $map ); + + if ( ! $field ) { + unset( $map[ (string) $source['id'] ] ); + continue; + } + + if ( 0 === strpos( (string) atf_wpforms_type( $source ), 'payment-' ) && 'total' !== $field['type'] ) { + $payments[] = $field['id']; + } + + // A payment total sums every payment field before it — that is what + // the running total on the source form showed. + if ( 'total' === $field['type'] && $payments ) { + $field['formula'] = '{' . implode( '} + {', $payments ) . '}'; + } + + $fields[] = $field; + } + + $schema = array( + 'fields' => $fields, + 'settings' => array(), + 'notifications' => array(), + 'confirmations' => array(), + ); + + if ( ! empty( $settings['submit_text'] ) ) { + $schema['settings']['submitLabel'] = (string) $settings['submit_text']; + } + + if ( ! empty( $settings['notification_enable'] ) && ! empty( $settings['notifications'] ) && is_array( $settings['notifications'] ) ) { + foreach ( $settings['notifications'] as $notification ) { + if ( ! is_array( $notification ) ) { + continue; + } + + $schema['notifications'][] = array( + 'name' => isset( $notification['notification_name'] ) && '' !== $notification['notification_name'] + ? (string) $notification['notification_name'] + : __( 'Notification', 'allterrain-forms' ), + 'to' => atf_wpforms_replace_tags( isset( $notification['email'] ) ? (string) $notification['email'] : '', $map ), + 'subject' => atf_wpforms_replace_tags( isset( $notification['subject'] ) ? (string) $notification['subject'] : '', $map ), + 'message' => atf_wpforms_replace_tags( isset( $notification['message'] ) ? (string) $notification['message'] : '', $map ), + 'fromName' => atf_wpforms_replace_tags( isset( $notification['sender_name'] ) ? (string) $notification['sender_name'] : '', $map ), + 'fromEmail' => atf_wpforms_replace_tags( isset( $notification['sender_address'] ) ? (string) $notification['sender_address'] : '', $map ), + 'replyTo' => atf_wpforms_replace_tags( isset( $notification['replyto'] ) ? (string) $notification['replyto'] : '', $map ), + 'logic' => atf_wpforms_logic( $notification, $map ), + ); + } + } + + if ( ! empty( $settings['confirmations'] ) && is_array( $settings['confirmations'] ) ) { + foreach ( $settings['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_wpforms_replace_tags( isset( $confirmation['message'] ) ? (string) $confirmation['message'] : '', $map ), + 'url' => isset( $confirmation['redirect'] ) ? (string) $confirmation['redirect'] : '', + 'pageId' => isset( $confirmation['page'] ) ? absint( $confirmation['page'] ) : 0, + 'logic' => atf_wpforms_logic( $confirmation, $map ), + ); + } + } + + return $schema; +} + +/** + * A WPForms field's type slug. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @return string + */ +function atf_wpforms_type( $source ) { + return isset( $source['type'] ) ? strtolower( (string) $source['type'] ) : ''; +} + +/** + * Converts one WPForms field. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @param string $id The id the new field will take. + * @param array $map WPForms field id => new field id, for logic rules. + * @return array|null The field, or null when the source has no equivalent. + */ +function atf_wpforms_field( $source, $id, $map ) { + $type = atf_wpforms_type( $source ); + + $simple = array( + 'text' => 'text', + 'textarea' => 'textarea', + 'email' => 'email', + 'url' => 'url', + 'phone' => 'tel', + 'number' => 'number', + 'number-slider' => 'range', + 'hidden' => 'hidden', + 'password' => 'password', + 'rating' => 'rating', + 'file-upload' => 'file', + 'signature' => 'signature', + 'pagebreak' => 'page_break', + 'net_promoter_score' => 'scale', + 'payment-total' => 'total', + 'gdpr-checkbox' => 'consent', + 'payment-single' => 'number', + 'color' => 'color', + ); + + $choicey = array( + 'select' => 'select', + 'radio' => 'radio', + 'checkbox' => 'checkboxes', + 'payment-select' => 'select', + 'payment-multiple' => 'radio', + 'payment-checkbox' => 'checkboxes', + 'likert_scale' => 'likert', + ); + + $field = array( + 'id' => $id, + 'label' => isset( $source['label'] ) ? (string) $source['label'] : '', + 'required' => ! empty( $source['required'] ), + 'logic' => atf_wpforms_logic( $source, $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['default_value'] ) && is_scalar( $source['default_value'] ) && '' !== $source['default_value'] ) { + $field['default'] = (string) $source['default_value']; + } + + if ( isset( $simple[ $type ] ) ) { + $field['type'] = $simple[ $type ]; + + if ( 'number-slider' === $type ) { + foreach ( array( 'min', 'max', 'step' ) as $bound ) { + if ( isset( $source[ $bound ] ) && '' !== $source[ $bound ] ) { + $field[ $bound ] = (string) $source[ $bound ]; + } + } + } + + if ( 'gdpr-checkbox' === $type ) { + // The consent line is the single choice's label. + $choice = atf_wpforms_choices( $source ); + + if ( isset( $choice[0]['label'] ) && '' !== $choice[0]['label'] ) { + $field['consentText'] = $choice[0]['label']; + } + + // GDPR consent cannot be optional in WPForms, and must not become + // optional by import. + $field['required'] = true; + } + + if ( 'file-upload' === $type && ! empty( $source['extensions'] ) ) { + $field['filetypes'] = array_values( + array_filter( array_map( 'trim', explode( ',', strtolower( (string) $source['extensions'] ) ) ) ) + ); + } + + if ( 'net_promoter_score' === $type ) { + $field['min'] = '0'; + $field['max'] = '10'; + + if ( ! empty( $source['lowest_label'] ) || ! empty( $source['highest_label'] ) ) { + $field['endlabels'] = array( + 'low' => isset( $source['lowest_label'] ) ? (string) $source['lowest_label'] : '', + 'high' => isset( $source['highest_label'] ) ? (string) $source['highest_label'] : '', + ); + } + } + + if ( 'rating' === $type && ! empty( $source['scale'] ) ) { + $field['max'] = (string) absint( $source['scale'] ); + } + + return $field; + } + + if ( isset( $choicey[ $type ] ) ) { + $field['type'] = $choicey[ $type ]; + $field['choices'] = atf_wpforms_choices( $source ); + + if ( 'select' === $type && ! empty( $source['multiple'] ) ) { + $field['type'] = 'multiselect'; + } + + if ( 'likert_scale' === $type ) { + $field['rows'] = atf_wpforms_likert_rows( $source ); + } + + return $field; + } + + switch ( $type ) { + case 'name': + $format = isset( $source['format'] ) ? (string) $source['format'] : 'first-last'; + + // A "simple" name is one box, which is a text field wearing a + // name label — a composite with one part would render a sublabel + // nobody wrote. + if ( 'simple' === $format ) { + $field['type'] = 'text'; + + return $field; + } + + $field['type'] = 'name'; + $field['parts'] = 'first-middle-last' === $format + ? array( 'first', 'middle', 'last' ) + : array( 'first', 'last' ); + + return $field; + + case 'address': + $field['type'] = 'address'; + + return $field; + + case 'date-time': + $format = isset( $source['format'] ) ? (string) $source['format'] : 'date-time'; + $types = array( + 'date' => 'date', + 'time' => 'time', + ); + $field['type'] = isset( $types[ $format ] ) ? $types[ $format ] : 'datetime'; + + return $field; + + case 'divider': + // A WPForms section divider is a heading with an optional + // description, not a horizontal rule. + $field['type'] = 'heading'; + + return $field; + + case 'html': + case 'content': + $field['type'] = 'html'; + $field['content'] = isset( $source['code'] ) ? (string) $source['code'] : ( isset( $source['content'] ) ? (string) $source['content'] : '' ); + + return $field; + + case 'captcha': + case 'entry-preview': + 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 WPForms choices map. + * + * WPForms keys choices by number and, for payment fields, keeps the price in + * `value`. The price survives as the choice's `price`, which is what lets an + * imported payment total keep calculating. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @return array[] + */ +function atf_wpforms_choices( $source ) { + $raw = isset( $source['choices'] ) && is_array( $source['choices'] ) ? $source['choices'] : array(); + $payment = 0 === strpos( atf_wpforms_type( $source ), 'payment-' ); + $choices = array(); + + foreach ( $raw as $choice ) { + if ( ! is_array( $choice ) ) { + continue; + } + + $label = isset( $choice['label'] ) ? (string) $choice['label'] : ''; + $value = isset( $choice['value'] ) ? (string) $choice['value'] : ''; + + if ( '' === $label && '' === $value ) { + continue; + } + + $converted = array( + 'label' => '' !== $label ? $label : $value, + // WPForms mostly submits the label itself; a non-payment `value` + // is the rare "show different value" option. + 'value' => $payment || '' === $value ? ( '' !== $label ? $label : $value ) : $value, + ); + + if ( $payment && '' !== $value ) { + $converted['price'] = (float) preg_replace( '/[^0-9.\-]/', '', $value ); + } + + if ( ! empty( $choice['default'] ) ) { + $converted['selected'] = true; + } + + $choices[] = $converted; + } + + return $choices; +} + +/** + * Converts a Likert grid's rows. + * + * @since 0.2.0 + * + * @param array $source The source field. + * @return string[] + */ +function atf_wpforms_likert_rows( $source ) { + $rows = array(); + + if ( isset( $source['rows'] ) && is_array( $source['rows'] ) ) { + foreach ( $source['rows'] as $row ) { + if ( is_scalar( $row ) && '' !== (string) $row ) { + $rows[] = (string) $row; + } + } + } + + return $rows; +} + +/** + * Converts WPForms conditional logic. + * + * WPForms nests rules as groups-of-rules — groups OR together, rules inside a + * group AND together. This plugin's logic is one flat group with a match mode, + * so the two shapes that translate exactly are translated: one group becomes + * `match: all`, and several single-rule groups become `match: any`. Anything + * else keeps its first group, which narrows the condition rather than widening + * it — a field that shows too rarely is visible in testing, a field that shows + * to everybody is a leak. + * + * @since 0.2.0 + * + * @param array $source The source field or setting block. + * @param array $map WPForms field id => new field id. + * @return array The logic block, disabled when there was none. + */ +function atf_wpforms_logic( $source, $map ) { + if ( empty( $source['conditional_logic'] ) || empty( $source['conditionals'] ) || ! is_array( $source['conditionals'] ) ) { + return array( 'enabled' => false ); + } + + $operators = array( + '==' => 'is', + '!=' => 'is_not', + 'c' => 'contains', + '!c' => 'not_contains', + '^' => 'starts_with', + '~' => 'ends_with', + 'e' => 'empty', + '!e' => 'not_empty', + '>' => 'greater', + '<' => 'less', + ); + + $groups = array(); + + foreach ( $source['conditionals'] as $group ) { + if ( ! is_array( $group ) ) { + continue; + } + + $rules = array(); + + foreach ( $group as $rule ) { + if ( ! is_array( $rule ) || ! isset( $rule['field'] ) || ! isset( $map[ (string) $rule['field'] ] ) ) { + continue; + } + + $operator = isset( $rule['operator'] ) ? (string) $rule['operator'] : '=='; + + $rules[] = array( + 'field' => $map[ (string) $rule['field'] ], + 'operator' => isset( $operators[ $operator ] ) ? $operators[ $operator ] : 'is', + 'value' => isset( $rule['value'] ) && is_scalar( $rule['value'] ) ? (string) $rule['value'] : '', + ); + } + + if ( $rules ) { + $groups[] = $rules; + } + } + + if ( ! $groups ) { + return array( 'enabled' => false ); + } + + $action = isset( $source['conditional_type'] ) && 'hide' === $source['conditional_type'] ? 'hide' : 'show'; + + if ( 1 === count( $groups ) ) { + return array( + 'enabled' => true, + 'action' => $action, + 'match' => 'all', + 'rules' => $groups[0], + ); + } + + $singles = array_filter( + $groups, + static function ( $rules ) { + return 1 === count( $rules ); + } + ); + + if ( count( $singles ) === count( $groups ) ) { + return array( + 'enabled' => true, + 'action' => $action, + 'match' => 'any', + 'rules' => array_merge( ...$groups ), + ); + } + + return array( + 'enabled' => true, + 'action' => $action, + 'match' => 'all', + 'rules' => $groups[0], + ); +} + +/** + * Rewrites WPForms smart tags onto this plugin's merge tags. + * + * `{field_id="3"}` becomes `{field:…}`; the specials that have equivalents are + * translated; a tag with no equivalent is left as it stands rather than + * silently deleted — visible in the editor, where the author can decide what + * it should say. + * + * @since 0.2.0 + * + * @param string $text The template text. + * @param array $map WPForms field id => new field id. + * @return string + */ +function atf_wpforms_replace_tags( $text, $map ) { + $specials = array( + '{all_fields}' => '{all_fields}', + '{admin_email}' => '{admin_email}', + '{site_name}' => '{site}', + '{site_url}' => '{site:url}', + '{page_url}' => '{referrer}', + '{user_ip}' => '{ip}', + '{date format="m/d/Y"}' => '{date}', + '{entry_date format="m/d/Y"}' => '{date}', + ); + + $text = strtr( (string) $text, $specials ); + + return preg_replace_callback( + '/\{field_id="(\d+)"\}|\{field_value_id="(\d+)"\}|\{field_html_id="(\d+)"\}/', + static function ( $found ) use ( $map ) { + $source_id = ''; + + for ( $i = 1; $i <= 3; $i++ ) { + if ( isset( $found[ $i ] ) && '' !== $found[ $i ] ) { + $source_id = $found[ $i ]; + break; + } + } + + return isset( $map[ $source_id ] ) ? '{field:' . $map[ $source_id ] . '}' : $found[0]; + }, + $text + ); +} diff --git a/readme.txt b/readme.txt index e4ee3a5..8846ef2 100644 --- a/readme.txt +++ b/readme.txt @@ -97,7 +97,9 @@ 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 is supported today; WPForms and Gravity Forms are next. +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. = Anti-spam that does not charge your visitors = @@ -177,10 +179,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 — -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. WPForms and Gravity Forms importers are on the way. +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. = Can I move a form to another site? = diff --git a/tests/phpunit/tests/importersWpforms.php b/tests/phpunit/tests/importersWpforms.php new file mode 100644 index 0000000..511569a --- /dev/null +++ b/tests/phpunit/tests/importersWpforms.php @@ -0,0 +1,398 @@ + array( + 0 => array( + 'id' => '0', + 'type' => 'name', + 'label' => 'Name', + 'format' => 'first-last', + 'required' => '1', + ), + 1 => array( + 'id' => '1', + 'type' => 'email', + 'label' => 'Email', + 'required' => '1', + ), + 2 => array( + 'id' => '2', + 'type' => 'textarea', + 'label' => 'Comment or Message', + 'description' => 'Tell us everything.', + ), + ), + 'settings' => array( + 'form_title' => 'Simple Contact Form', + 'submit_text' => 'Send it in', + 'notification_enable' => '1', + 'notifications' => array( + 1 => array( + 'notification_name' => 'Default Notification', + 'email' => '{admin_email}', + 'subject' => 'New Entry: Simple Contact Form', + 'sender_name' => '{site_name}', + 'sender_address' => '{admin_email}', + 'replyto' => '{field_id="1"}', + 'message' => '{all_fields}', + ), + ), + 'confirmations' => array( + 1 => array( + 'type' => 'message', + 'message' => '

Thanks for contacting us!

', + ), + ), + ), + ); + } + + /** + * The Lite contact template converts field for field. + * + * @covers ::atf_wpforms_convert + */ + public function test_contact_document_converts() { + $schema = atf_normalize_schema( atf_wpforms_convert( $this->contact_document() ) ); + + $this->assertSame( array( 'name', 'email', 'textarea' ), wp_list_pluck( $schema['fields'], 'type' ) ); + $this->assertSame( array( 'first', 'last' ), $schema['fields'][0]['parts'] ); + $this->assertTrue( $schema['fields'][0]['required'] ); + $this->assertSame( 'Tell us everything.', $schema['fields'][2]['hint'] ); + $this->assertSame( 'Send it in', $schema['settings']['submitLabel'] ); + + $mail = $schema['notifications'][0]; + + $this->assertSame( '{admin_email}', $mail['to'] ); + $this->assertSame( '{site}', $mail['fromName'] ); + $this->assertSame( '{field:f2}', $mail['replyTo'] ); + $this->assertSame( '{all_fields}', $mail['message'] ); + + $this->assertSame( 'Thanks for contacting us!', trim( wp_strip_all_tags( $schema['confirmations'][0]['message'] ) ) ); + } + + /** + * A simple-format name is one box, so it becomes a text field. + * + * @covers ::atf_wpforms_field + */ + public function test_simple_name_becomes_text() { + $fields = atf_wpforms_convert( + array( + 'fields' => array( + array( + 'id' => '0', + 'type' => 'name', + 'label' => 'Name', + 'format' => 'simple', + ), + array( + 'id' => '1', + 'type' => 'name', + 'label' => 'Full name', + 'format' => 'first-middle-last', + ), + ), + ) + )['fields']; + + $this->assertSame( 'text', $fields[0]['type'] ); + $this->assertSame( 'name', $fields[1]['type'] ); + $this->assertSame( array( 'first', 'middle', 'last' ), $fields[1]['parts'] ); + } + + /** + * Payment choices keep their prices and the total keeps calculating. + * + * @covers ::atf_wpforms_field + * @covers ::atf_wpforms_choices + */ + public function test_payment_fields_keep_their_prices() { + $schema = atf_wpforms_convert( + array( + 'fields' => array( + array( + 'id' => '3', + 'type' => 'payment-multiple', + 'label' => 'Ticket', + 'choices' => array( + 1 => array( + 'label' => 'Standard', + 'value' => '25.00', + ), + 2 => array( + 'label' => 'VIP', + 'value' => '100.00', + ), + ), + ), + array( + 'id' => '4', + 'type' => 'payment-checkbox', + 'label' => 'Extras', + 'choices' => array( + 1 => array( + 'label' => 'Parking', + 'value' => '$10.00', + ), + ), + ), + array( + 'id' => '5', + 'type' => 'payment-total', + '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( 'total', $fields[2]['type'] ); + $this->assertSame( '{f1} + {f2}', $fields[2]['formula'] ); + + // The formula computes through the real engine. + $values = atf_apply_calculations( + atf_normalize_schema( $schema ), + array( + 'f1' => 'VIP', + 'f2' => array( 'Parking' ), + ) + ); + + $this->assertSame( 110.0, (float) $values['f3'] ); + } + + /** + * Conditional logic converts, both shapes that translate exactly. + * + * @covers ::atf_wpforms_logic + */ + public function test_conditional_logic_converts() { + $fields = atf_wpforms_convert( + array( + 'fields' => array( + array( + 'id' => '0', + 'type' => 'radio', + 'label' => 'Attending?', + ), + array( + 'id' => '1', + 'type' => 'text', + 'label' => 'One group, two rules', + 'conditional_logic' => '1', + 'conditional_type' => 'show', + 'conditionals' => array( + array( + array( + 'field' => '0', + 'operator' => '==', + 'value' => 'Yes', + ), + array( + 'field' => '0', + 'operator' => '!e', + 'value' => '', + ), + ), + ), + ), + array( + 'id' => '2', + 'type' => 'text', + 'label' => 'Two single-rule groups', + 'conditional_logic' => '1', + 'conditional_type' => 'hide', + 'conditionals' => array( + array( + array( + 'field' => '0', + 'operator' => 'c', + 'value' => 'A', + ), + ), + array( + array( + 'field' => '0', + 'operator' => '^', + 'value' => 'B', + ), + ), + ), + ), + ), + ) + )['fields']; + + $all = $fields[1]['logic']; + + $this->assertTrue( $all['enabled'] ); + $this->assertSame( 'show', $all['action'] ); + $this->assertSame( 'all', $all['match'] ); + $this->assertSame( array( 'f1', 'f1' ), wp_list_pluck( $all['rules'], 'field' ) ); + $this->assertSame( array( 'is', 'not_empty' ), wp_list_pluck( $all['rules'], 'operator' ) ); + + $any = $fields[2]['logic']; + + $this->assertSame( 'hide', $any['action'] ); + $this->assertSame( 'any', $any['match'] ); + $this->assertSame( array( 'contains', 'starts_with' ), wp_list_pluck( $any['rules'], 'operator' ) ); + } + + /** + * Pro and add-on fields have equivalents; the unknown stays visible. + * + * @covers ::atf_wpforms_field + */ + public function test_pro_field_mapping() { + $fields = atf_wpforms_convert( + array( + 'fields' => array( + array( + 'id' => '0', + 'type' => 'phone', + 'label' => 'Phone', + ), + array( + 'id' => '1', + 'type' => 'date-time', + 'label' => 'When', + 'format' => 'date', + ), + array( + 'id' => '2', + 'type' => 'file-upload', + 'label' => 'CV', + 'extensions' => 'pdf, docx', + ), + array( + 'id' => '3', + 'type' => 'net_promoter_score', + 'label' => 'How likely…', + 'lowest_label' => 'Not at all', + 'highest_label' => 'Extremely', + ), + array( + 'id' => '4', + 'type' => 'pagebreak', + 'label' => 'Next', + ), + array( + 'id' => '5', + 'type' => 'likert_scale', + 'label' => 'Agree?', + 'rows' => array( 'The docs are clear', 'The API is stable' ), + 'choices' => array( + 1 => array( 'label' => 'Agree' ), + 2 => array( 'label' => 'Disagree' ), + ), + ), + array( + 'id' => '6', + 'type' => 'exotic-addon-widget', + 'label' => 'Mystery', + ), + array( + 'id' => '7', + 'type' => 'gdpr-checkbox', + 'label' => 'GDPR Agreement', + 'choices' => array( + 1 => array( 'label' => 'I consent to having this website store my submitted information.' ), + ), + ), + ), + ) + )['fields']; + + $this->assertSame( 'tel', $fields[0]['type'] ); + $this->assertSame( 'date', $fields[1]['type'] ); + $this->assertSame( array( 'pdf', 'docx' ), $fields[2]['filetypes'] ); + + $this->assertSame( 'scale', $fields[3]['type'] ); + $this->assertSame( 'Not at all', $fields[3]['endlabels']['low'] ); + + $this->assertSame( 'page_break', $fields[4]['type'] ); + + $this->assertSame( 'likert', $fields[5]['type'] ); + $this->assertSame( array( 'The docs are clear', 'The API is stable' ), $fields[5]['rows'] ); + $this->assertCount( 2, $fields[5]['choices'] ); + + $this->assertSame( 'text', $fields[6]['type'], 'The unknown add-on field stays visible.' ); + + $this->assertSame( 'consent', $fields[7]['type'] ); + $this->assertTrue( $fields[7]['required'], 'GDPR consent must not become optional by import.' ); + $this->assertStringContainsString( 'store my submitted information', $fields[7]['consentText'] ); + } + + /** + * The whole trip: a wpforms post becomes a working form. + * + * @covers ::atf_wpforms_import + */ + public function test_wpforms_import_end_to_end() { + atf_add_capabilities(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $source = self::factory()->post->create( + array( + 'post_type' => 'wpforms', + 'post_title' => 'Simple Contact Form', + 'post_content' => wp_slash( wp_json_encode( $this->contact_document() ) ), + ) + ); + + $form_id = atf_import_source_form( 'wpforms', (string) $source ); + + $this->assertIsInt( $form_id ); + $this->assertSame( 'Simple Contact Form', get_post( $form_id )->post_title ); + + $schema = atf_get_form_schema( $form_id ); + + $this->assertCount( 3, $schema['fields'] ); + $this->assertSame( '{admin_email}', $schema['notifications'][0]['to'] ); + + // The source is untouched. + $this->assertSame( 'wpforms', get_post( $source )->post_type ); + + // A post whose JSON is broken fails cleanly. + $broken = self::factory()->post->create( + array( + 'post_type' => 'wpforms', + 'post_content' => 'not json', + ) + ); + + $this->assertWPError( atf_import_source_form( 'wpforms', (string) $broken ) ); + } +}