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(
+ '
',
+ esc_html( $headline ),
+ esc_html( $detail ),
+ // The whole job, in one button.
+ sprintf(
+ ' ',
+ 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(
+ '',
+ 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(
+ '',
+ 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'] );
+ }
+}