From 7f94a84f7564c7feb70ed1fd351d1d15a462e8db Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Tue, 18 Aug 2026 13:46:44 +0200 Subject: [PATCH] Offer the import instead of waiting to be found An importer nobody finds is an importer nobody uses, and the moment it is worth the most is the one nobody goes looking for it: just after activating this plugin on a site that already has fifteen forms in another one. So the plugin looks for them itself and says so -- "AllTerrain Forms found 6 forms in Contact Form 7" -- with one button that brings all of them over, from every source at once. Three rules keep that from becoming the kind of notice people install other plugins to hide. It appears only where it is relevant: this plugin's screens and the Plugins screen, where an activation lands. "Not now" is remembered per user, for good, because one administrator dismissing it must not answer for the person who actually built those forms. And it stops offering the moment anything has been imported -- by then the Import page is somewhere you go, not something that asks. The survey behind it costs a query per source, so it is cached for twelve hours and forgotten whenever a form is imported or a plugin is switched on or off. `atf_show_import_notice` turns the whole thing off for a site that would rather introduce it its own way. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0119U4sRRWGcTQdwYreTwpAp --- allterrain-forms.php | 1 + docs/hooks-reference.md | 18 ++ includes/import-notice.php | 243 +++++++++++++++++++++ includes/importers.php | 149 ++++++++++++- readme.txt | 28 ++- tests/phpunit/tests/importNotice.php | 310 +++++++++++++++++++++++++++ 6 files changed, 739 insertions(+), 10 deletions(-) create mode 100644 includes/import-notice.php create mode 100644 tests/phpunit/tests/importNotice.php 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'] ); + } +}