diff --git a/allterrain-forms.php b/allterrain-forms.php index 312d121..40a01ad 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -158,6 +158,7 @@ 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/import-notice.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/docs/hooks-reference.md b/docs/hooks-reference.md index bd6ea06..a92393b 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -817,6 +817,24 @@ The converted schema, before it is normalised and saved. The place to correct a mapping the converter got wrong for your data — rename a field, add a notification — without forking the importer. +### `atf_show_import_notice` — Filter — *Experimental* + +```php +apply_filters( 'atf_show_import_notice', bool $show ); +``` + +Whether the plugin offers the import at all. When forms are found in another +plugin, an administrator sees one notice — on this plugin's own screens and on +the Plugins screen, where an activation lands — with a button that imports +every one of them. Return `false` to introduce the importer your own way rather +than teaching each administrator to dismiss it. + +The offer already stops on its own: "Not now" is remembered per user +(`atf_import_notice_dismissed` user meta), and nothing is offered once anything +has been imported (`atf_has_imported` option). What the survey found is cached +for twelve hours and dropped whenever a form is imported or a plugin is +activated or deactivated. + ### `atf_form_imported` — Action — *Experimental* ```php diff --git a/includes/import-notice.php b/includes/import-notice.php new file mode 100644 index 0000000..1d044b4 --- /dev/null +++ b/includes/import-notice.php @@ -0,0 +1,243 @@ +id ) { + return true; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading which screen is being rendered; nothing is written. + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; + + // Every Forms screen except the Import page itself, where a notice + // pointing at the page you are on is just noise. + return 0 === strpos( $page, 'allterrain-forms' ) && 'allterrain-forms-import' !== $page; +} + +/** + * Whether to offer the import. + * + * Kept apart from the markup so the decision can be tested without capturing + * output, and because every clause in it is one somebody could reasonably get + * wrong later. + * + * @since 0.2.0 + * + * @return bool + */ +function atf_should_show_import_notice() { + if ( ! atf_can_edit_forms() ) { + return false; + } + + if ( get_option( ATF_IMPORTED_OPTION ) ) { + return false; + } + + if ( get_user_meta( get_current_user_id(), ATF_IMPORT_NOTICE_META, true ) ) { + return false; + } + + /** + * Filters whether the import offer is shown at all. + * + * A site that would rather introduce the importer its own way turns this + * off once, rather than teaching every administrator to dismiss it. + * + * @since 0.2.0 + * + * @param bool $show Whether to show it. + */ + if ( ! apply_filters( 'atf_show_import_notice', true ) ) { + return false; + } + + return atf_importable_count() > 0; +} + +/** + * Renders the offer. + * + * @since 0.2.0 + * + * @return void + */ +function atf_render_import_notice() { + if ( ! atf_import_notice_screen() || ! atf_should_show_import_notice() ) { + return; + } + + $total = atf_importable_count(); + $sources = atf_importable_forms(); + + // One source names itself; several are counted out, because "4 forms in + // other plugins" leaves somebody wondering which, and the answer is the + // reason they would click. + if ( 1 === count( $sources ) ) { + $source = reset( $sources ); + + $headline = sprintf( + /* translators: 1: number of forms found, 2: the plugin they are in. */ + _n( + 'AllTerrain Forms found %1$d form in %2$s.', + 'AllTerrain Forms found %1$d forms in %2$s.', + $total, + 'allterrain-forms' + ), + $total, + $source['label'] + ); + + $detail = __( 'Each one is copied with its fields, notification emails and thank-you message. The originals are never changed.', 'allterrain-forms' ); + } else { + $counts = array(); + + foreach ( $sources as $source ) { + $counts[] = sprintf( + /* translators: 1: number of forms, 2: name of the plugin they are in. */ + __( '%1$d from %2$s', 'allterrain-forms' ), + (int) $source['count'], + $source['label'] + ); + } + + $headline = sprintf( + /* translators: %d: how many forms were found across every other plugin. */ + _n( + 'AllTerrain Forms found %d form in another plugin.', + 'AllTerrain Forms found %d forms in other plugins.', + $total, + 'allterrain-forms' + ), + $total + ); + + $detail = sprintf( + /* translators: %s: a list like "3 from Contact Form 7 and 1 from Gravity Forms". */ + __( '%s. Each one is copied with its fields, notification emails and thank-you message. The originals are never changed.', 'allterrain-forms' ), + wp_sprintf( '%l', $counts ) + ); + } + + printf( + '

%1$s

%2$s

%3$s%4$s%5$s

', + esc_html( $headline ), + esc_html( $detail ), + // The whole job, in one button. + sprintf( + '
+ + + + %2$s + +
', + esc_url( admin_url( 'admin-post.php' ) ), + wp_nonce_field( 'atf-import', '_atf_nonce', true, false ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_nonce_field() builds escaped markup. + esc_html( + sprintf( + /* translators: %d: how many forms would be imported. */ + _n( 'Import %d form', 'Import all %d forms', $total, 'allterrain-forms' ), + $total + ) + ) + ), + sprintf( + '%2$s ', + esc_url( admin_url( 'admin.php?page=allterrain-forms-import' ) ), + esc_html__( 'Choose which', 'allterrain-forms' ) + ), + // A real answer, remembered -- not the X in the corner, which forgets + // the moment the page reloads. + sprintf( + '
+ + %2$s + +
', + esc_url( admin_url( 'admin-post.php' ) ), + wp_nonce_field( 'atf-dismiss-import', '_atf_nonce', true, false ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_nonce_field() builds escaped markup. + esc_html__( 'Not now', 'allterrain-forms' ) + ) + ); +} +add_action( 'admin_notices', 'atf_render_import_notice' ); + +/** + * Remembers that this user does not want to be asked again. + * + * @since 0.2.0 + * + * @return void + */ +function atf_handle_dismiss_import_notice() { + check_admin_referer( 'atf-dismiss-import', '_atf_nonce' ); + + update_user_meta( get_current_user_id(), ATF_IMPORT_NOTICE_META, '1' ); + + wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); + exit; +} +add_action( 'admin_post_atf_dismiss_import_notice', 'atf_handle_dismiss_import_notice' ); diff --git a/includes/importers.php b/includes/importers.php index 99efb8c..cdb5595 100644 --- a/includes/importers.php +++ b/includes/importers.php @@ -169,6 +169,120 @@ function atf_create_imported_form( $title, $schema, $importer_id, $source_id ) { return $form_id; } +/** + * How many forms each available importer can see, keyed by importer id. + * + * Cached, because this is asked on ordinary admin page loads to decide whether + * the import is worth offering at all -- and answering it costs a query per + * source, plus a `SHOW TABLES` for the one that keeps its forms in tables of + * its own. Twelve hours means nobody pays for it twice in a sitting, and the + * answer is forgotten the moment anything could have changed it: a form + * imported, or a plugin switched on or off. + * + * @since 0.2.0 + * + * @return array[] Importer id => { label, count }. A source with no forms is + * left out, so an empty array means there is nothing to offer. + */ +function atf_importable_forms() { + $cached = get_transient( 'atf_importable' ); + + if ( is_array( $cached ) ) { + return $cached; + } + + $found = array(); + + foreach ( atf_importers() as $id => $importer ) { + if ( ! call_user_func( $importer['available'] ) ) { + continue; + } + + $count = count( (array) call_user_func( $importer['forms'] ) ); + + if ( $count > 0 ) { + $found[ $id ] = array( + 'label' => $importer['label'], + 'count' => $count, + ); + } + } + + set_transient( 'atf_importable', $found, 12 * HOUR_IN_SECONDS ); + + return $found; +} + +/** + * Forgets what the last survey found. + * + * @since 0.2.0 + * + * @return void + */ +function atf_forget_importable_forms() { + delete_transient( 'atf_importable' ); +} +add_action( 'atf_form_imported', 'atf_forget_importable_forms' ); +add_action( 'activated_plugin', 'atf_forget_importable_forms' ); +add_action( 'deactivated_plugin', 'atf_forget_importable_forms' ); + +/** + * How many forms could be imported, across every source. + * + * @since 0.2.0 + * + * @return int + */ +function atf_importable_count() { + $total = 0; + + foreach ( atf_importable_forms() as $source ) { + $total += (int) $source['count']; + } + + return $total; +} + +/** + * Imports every form every available source can see. + * + * The one-click path. The importer ids are taken once, up front, because each + * successful import forgets the survey -- re-reading it inside the loop would + * re-run every source's query on every form. + * + * @since 0.2.0 + * + * @return array { + * @type int $imported How many forms were created. + * @type int $failed How many could not be. + * } + */ +function atf_import_all() { + $importers = atf_importers(); + $imported = 0; + $failed = 0; + + foreach ( array_keys( atf_importable_forms() ) as $importer_id ) { + if ( ! isset( $importers[ $importer_id ] ) ) { + continue; + } + + foreach ( array_keys( (array) call_user_func( $importers[ $importer_id ]['forms'] ) ) as $source_id ) { + if ( is_wp_error( atf_import_source_form( $importer_id, (string) $source_id ) ) ) { + ++$failed; + } else { + ++$imported; + } + } + } + + return array( + 'imported' => $imported, + 'failed' => $failed, + ); +} + /** * Registers the Import page. * @@ -260,6 +374,32 @@ function atf_render_import_page() { esc_html__( 'Each import creates a new AllTerrain form. The original is never changed, so importing is safe to try and safe to repeat.', 'allterrain-forms' ) ); + // The whole job in one button, above the per-source lists. Somebody + // arriving here after switching plugins wants all of it; picking through + // the lists is the exception, so it comes second. + $total = atf_importable_count(); + + if ( $total > 0 ) { + printf( + '
+ + + + %2$s + +
', + esc_url( admin_url( 'admin-post.php' ) ), + wp_nonce_field( 'atf-import', '_atf_nonce', true, false ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_nonce_field() builds escaped markup. + esc_html( + sprintf( + /* translators: %d: total number of forms found across every source. */ + _n( 'Import all %d form', 'Import all %d forms', $total, 'allterrain-forms' ), + $total + ) + ) + ); + } + foreach ( $available as $id => $importer ) { $forms = call_user_func( $importer['forms'] ); @@ -342,7 +482,14 @@ function atf_handle_import_post() { $imported = 0; $failed = 0; - if ( isset( $importers[ $importer_id ] ) ) { + // `all` is the one-click path: every form every source can see. Checked + // against the registry first, so a third-party importer that calls itself + // `all` keeps its own meaning rather than being shadowed by this. + if ( 'all' === $importer_id && ! isset( $importers['all'] ) ) { + $result = atf_import_all(); + $imported = $result['imported']; + $failed = $result['failed']; + } elseif ( isset( $importers[ $importer_id ] ) ) { $sources = '' !== $source ? array( $source ) : array_keys( call_user_func( $importers[ $importer_id ]['forms'] ) ); diff --git a/readme.txt b/readme.txt index 19de8a2..0214fac 100644 --- a/readme.txt +++ b/readme.txt @@ -91,11 +91,16 @@ on another site. No code, no build step. = Switching? Bring your forms with you = -**Forms → Import** finds the forms other plugins left in your database — even -after you deactivated them — and turns each one into an ordinary AllTerrain -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. +You do not have to go looking. Activate the plugin and it tells you what it +found — *"AllTerrain Forms found 6 forms in Contact Form 7"* — with one button +that brings all of them over. Each becomes an ordinary AllTerrain 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. + +**Forms → Import** is there too, when you would rather pick through them one at +a time. Both find forms other plugins left in your database — even after you +deactivated them. Contact Form 7, WPForms and Gravity Forms are all supported — imports carry conditional logic across, payment and product fields keep their prices, an @@ -180,10 +185,15 @@ 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, -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. +Yes, and it offers before you ask: if the plugin finds forms from Contact Form +7, WPForms or Gravity Forms it says so and gives you a single button that +imports all of them. It asks once — "Not now" is remembered, and it stops +offering entirely once anything has been imported. + +**Forms → Import** lists them individually whenever you want it, 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/importNotice.php b/tests/phpunit/tests/importNotice.php new file mode 100644 index 0000000..d196159 --- /dev/null +++ b/tests/phpunit/tests/importNotice.php @@ -0,0 +1,310 @@ +user->create( array( 'role' => 'administrator' ) ) ); + + atf_forget_importable_forms(); + delete_option( ATF_IMPORTED_OPTION ); + } + + /** + * Leaves nothing cached for the next test. + */ + public function tear_down() { + atf_forget_importable_forms(); + + parent::tear_down(); + } + + /** + * Creates a Contact Form 7 form. + * + * @param string $title Its title. + * @return int The post id. + */ + private function cf7_form( $title ) { + $id = self::factory()->post->create( + array( + 'post_type' => 'wpcf7_contact_form', + 'post_title' => $title, + ) + ); + + update_post_meta( $id, '_form', '[text* your-name] [submit "Send"]' ); + + return $id; + } + + /** + * Creates a WPForms form. + * + * @param string $title Its title. + * @return int The post id. + */ + private function wpforms_form( $title ) { + return self::factory()->post->create( + array( + 'post_type' => 'wpforms', + 'post_title' => $title, + 'post_content' => wp_slash( + wp_json_encode( + array( + 'fields' => array( + array( + 'id' => '0', + 'type' => 'email', + 'label' => 'Email', + ), + ), + ) + ) + ), + ) + ); + } + + /** + * Nothing to import is the answer on an ordinary site. + * + * @covers ::atf_importable_forms + * @covers ::atf_should_show_import_notice + */ + public function test_a_site_with_no_other_forms_is_not_offered_an_import() { + $this->assertSame( array(), atf_importable_forms() ); + $this->assertSame( 0, atf_importable_count() ); + $this->assertFalse( atf_should_show_import_notice() ); + } + + /** + * Each source is counted, and named. + * + * @covers ::atf_importable_forms + * @covers ::atf_importable_count + */ + public function test_every_source_is_found_and_counted() { + $this->cf7_form( 'Contact' ); + $this->cf7_form( 'Careers' ); + $this->wpforms_form( 'Newsletter' ); + + $found = atf_importable_forms(); + + $this->assertSame( array( 'contact-form-7', 'wpforms' ), array_keys( $found ) ); + $this->assertSame( 2, $found['contact-form-7']['count'] ); + $this->assertSame( 'Contact Form 7', $found['contact-form-7']['label'] ); + $this->assertSame( 1, $found['wpforms']['count'] ); + $this->assertSame( 3, atf_importable_count() ); + } + + /** + * The survey is cached, and forgotten when it could be wrong. + * + * @covers ::atf_importable_forms + * @covers ::atf_forget_importable_forms + */ + public function test_the_survey_is_cached_until_something_changes() { + $this->cf7_form( 'Contact' ); + + $this->assertSame( 1, atf_importable_count() ); + + // A second form does not appear while the answer is still cached -- + // which is the point of the cache, and why anything that could change + // the answer has to drop it. + $this->cf7_form( 'Careers' ); + $this->assertSame( 1, atf_importable_count() ); + + atf_forget_importable_forms(); + $this->assertSame( 2, atf_importable_count() ); + } + + /** + * One click imports every form from every source. + * + * @covers ::atf_import_all + */ + public function test_import_all_takes_everything() { + $this->cf7_form( 'Contact' ); + $this->cf7_form( 'Careers' ); + $this->wpforms_form( 'Newsletter' ); + + $result = atf_import_all(); + + $this->assertSame( 3, $result['imported'] ); + $this->assertSame( 0, $result['failed'] ); + + $titles = wp_list_pluck( + get_posts( + array( + 'post_type' => ATF_FORM_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 10, + ) + ), + 'post_title' + ); + + sort( $titles ); + + $this->assertSame( array( 'Careers', 'Contact', 'Newsletter' ), $titles ); + + // The sources are left exactly as they were. + $this->assertCount( 2, get_posts( array( 'post_type' => 'wpcf7_contact_form' ) ) ); + $this->assertCount( 1, get_posts( array( 'post_type' => 'wpforms' ) ) ); + } + + /** + * The offer is made when there is something to offer. + * + * @covers ::atf_should_show_import_notice + */ + public function test_the_offer_is_made_when_forms_are_found() { + $this->cf7_form( 'Contact' ); + + $this->assertTrue( atf_should_show_import_notice() ); + } + + /** + * "Not now" is remembered, and only for the person who said it. + * + * @covers ::atf_should_show_import_notice + */ + public function test_not_now_is_remembered_per_user() { + $this->cf7_form( 'Contact' ); + + update_user_meta( get_current_user_id(), ATF_IMPORT_NOTICE_META, '1' ); + $this->assertFalse( atf_should_show_import_notice() ); + + // Somebody else's dismissal is not this person's answer. + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->assertTrue( atf_should_show_import_notice() ); + } + + /** + * Once anything has been imported the plugin stops asking. + * + * @covers ::atf_should_show_import_notice + * @covers ::atf_remember_import + */ + public function test_it_stops_asking_after_the_first_import() { + $this->cf7_form( 'Contact' ); + $this->cf7_form( 'Careers' ); + + $this->assertTrue( atf_should_show_import_notice() ); + + atf_import_source_form( 'contact-form-7', (string) $this->cf7_form( 'Another' ) ); + + // A form is left unimported, and it still stops -- the notice exists to + // introduce the importer, and the Import page does the rest. + $this->assertNotEmpty( get_option( ATF_IMPORTED_OPTION ) ); + $this->assertFalse( atf_should_show_import_notice() ); + } + + /** + * Somebody who cannot build forms is never offered the import. + * + * @covers ::atf_should_show_import_notice + */ + public function test_the_offer_needs_the_capability() { + $this->cf7_form( 'Contact' ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertFalse( atf_should_show_import_notice() ); + } + + /** + * A site can turn the offer off for everybody, once. + * + * @covers ::atf_should_show_import_notice + */ + public function test_a_filter_can_switch_the_offer_off() { + $this->cf7_form( 'Contact' ); + + add_filter( 'atf_show_import_notice', '__return_false' ); + + $this->assertFalse( atf_should_show_import_notice() ); + } + + /** + * The notice names one source, and counts out several. + * + * "4 forms in other plugins" leaves somebody wondering which, and which is + * the reason they would click. + * + * @covers ::atf_render_import_notice + */ + public function test_it_names_a_single_source_and_itemises_several() { + // The renderer bails on a screen it does not belong to, so put it on + // one it does. + $_GET['page'] = 'allterrain-forms'; + + $this->cf7_form( 'Contact' ); + $this->cf7_form( 'Careers' ); + + ob_start(); + atf_render_import_notice(); + $one = wp_strip_all_tags( ob_get_clean() ); + + $this->assertStringContainsString( 'found 2 forms in Contact Form 7', $one ); + $this->assertStringNotContainsString( 'other plugins', $one ); + + $this->wpforms_form( 'Newsletter' ); + atf_forget_importable_forms(); + + ob_start(); + atf_render_import_notice(); + $several = wp_strip_all_tags( ob_get_clean() ); + + $this->assertStringContainsString( 'found 3 forms in other plugins', $several ); + $this->assertStringContainsString( '2 from Contact Form 7', $several ); + $this->assertStringContainsString( '1 from WPForms', $several ); + $this->assertStringContainsString( 'Import all 3 forms', $several ); + + unset( $_GET['page'] ); + } + + /** + * The notice keeps off screens it has no business on. + * + * @covers ::atf_import_notice_screen + */ + public function test_it_only_appears_where_it_belongs() { + $GLOBALS['hook_suffix'] = 'plugins.php'; + set_current_screen( 'plugins' ); + $this->assertTrue( atf_import_notice_screen(), 'Where an activation lands.' ); + + set_current_screen( 'edit-post' ); + $this->assertFalse( atf_import_notice_screen(), 'An unrelated screen.' ); + + $_GET['page'] = 'allterrain-forms'; + $this->assertTrue( atf_import_notice_screen(), 'The builder.' ); + + $_GET['page'] = 'allterrain-forms-import'; + $this->assertFalse( atf_import_notice_screen(), 'The page it would point at.' ); + + unset( $_GET['page'] ); + } +}