Skip to content
Open

Release #2927

Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 110 additions & 6 deletions inc/plugins/class-dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,54 @@ private function the_otter_banner() {
max-height: 35px;
}

.o-export-split {
position: relative;
display: inline-flex;
}

.o-export-split #export-submissions {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

.o-export-split__toggle {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-left: none !important;
padding: 0 6px !important;
}

.o-export-split__menu {
position: absolute;
top: 100%;
right: 0;
z-index: 10;
margin: 4px 0 0;
padding: 4px 0;
list-style: none;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
min-width: 220px;
}

.o-export-split__item {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 13px;
}

.o-export-split__item:hover,
.o-export-split__item:focus {
background: #f0f0f1;
}

.wp-core-ui .button.o-locked-action,
.wp-core-ui .button.o-locked-action:focus {
display: inline-flex;
Expand Down Expand Up @@ -683,9 +731,25 @@ private function the_otter_banner() {
<h1 class="otter-banner__title" style="line-height: normal;"><?php esc_html_e( 'Form Submissions', 'otter-blocks' ); ?></h1>

<?php if ( Pro::is_pro_active() ) : ?>
<button id="export-submissions" class="button">
<?php esc_html_e( 'Export', 'otter-blocks' ); ?>
</button>
<div class="o-export-split">
<button id="export-submissions" class="button" data-format="xml">
<?php esc_html_e( 'Export', 'otter-blocks' ); ?>
</button>
<button
id="export-submissions-toggle"
type="button"
class="button o-export-split__toggle"
aria-controls="export-submissions-menu"
aria-expanded="false"
aria-label="<?php esc_attr_e( 'Choose file format', 'otter-blocks' ); ?>"
>
<span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
</button>
<ul id="export-submissions-menu" class="o-export-split__menu" hidden>
<li><button type="button" class="o-export-split__item" data-format="xml"><?php esc_html_e( 'Export as WordPress XML (WXR)', 'otter-blocks' ); ?></button></li>
<li><button type="button" class="o-export-split__item" data-format="csv"><?php esc_html_e( 'Export as CSV', 'otter-blocks' ); ?></button></li>
</ul>
</div>
<?php else : ?>
<span class="otter-banner__actions">
<span class="o-pro-notice"><?php esc_html_e( 'Filter and export form submissions with Otter Pro.', 'otter-blocks' ); ?></span>
Expand All @@ -706,14 +770,28 @@ class="button o-locked-action"
</div>
<script>
window.document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#export-submissions')?.addEventListener('click', () => {
const exportBtn = document.querySelector('#export-submissions');
const toggleBtn = document.querySelector('#export-submissions-toggle');
const menu = document.querySelector('#export-submissions-menu');

const closeMenu = () => {
if (!menu || !toggleBtn) {
return;
}

menu.setAttribute('hidden', '');
toggleBtn.setAttribute('aria-expanded', 'false');
};

const runExport = (format) => {
fetch('<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
action: 'otter_form_submissions',
format: format,
_nonce: '<?php echo esc_attr( wp_create_nonce( 'otter_form_export_submissions' ) ); ?>'
})
})
Expand All @@ -724,17 +802,43 @@ class="button o-locked-action"
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(currentDate.getDate()).padStart(2, '0');

const blob = new Blob([response], {type: 'text/xml'});
const isCsv = 'csv' === format;
const blob = new Blob([response], {type: isCsv ? 'text/csv;charset=utf-8' : 'text/xml'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `otter_form_submissions__${year}-${month}-${day}.xml`;
a.download = `otter_form_submissions__${year}-${month}-${day}.${isCsv ? 'csv' : 'xml'}`;
document.body.appendChild(a);
a.click();
})
.catch(error => console.error('Error:', error));
};

exportBtn?.addEventListener('click', () => runExport(exportBtn.dataset.format || 'xml'));

toggleBtn?.addEventListener('click', (event) => {
event.stopPropagation();

if (menu.hasAttribute('hidden')) {
menu.removeAttribute('hidden');
toggleBtn.setAttribute('aria-expanded', 'true');
} else {
closeMenu();
}
});

menu?.querySelectorAll('.o-export-split__item').forEach((item) => {
item.addEventListener('click', () => {
runExport(item.dataset.format);
closeMenu();
});
Comment on lines +831 to +834
});

document.addEventListener('click', (event) => {
if (menu && !menu.hasAttribute('hidden') && !menu.contains(event.target) && event.target !== toggleBtn) {
closeMenu();
}
});
})
</script>
<?php
Expand Down
118 changes: 115 additions & 3 deletions inc/plugins/class-form-records-export.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,125 @@ public function export_submissions() {
wp_die( esc_html( __( 'You are not allowed to export submissions.', 'otter-blocks' ) ) );
}

// Export submissions.
$format = isset( $_POST['format'] ) ? sanitize_key( wp_unslash( $_POST['format'] ) ) : 'xml'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

echo 'csv' === $format ? $this->export_csv() : $this->export_xml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_die();
}

/**
* Build the WordPress WXR (XML) export of all submissions.
*
* @return string
*/
private function export_xml() {
require_once ABSPATH . 'wp-admin/includes/export.php';

ob_start();
export_wp( array( 'content' => Form_Submissions::FORM_RECORD_TYPE ) );
$export = ob_get_clean();

echo ent2ncr( $export ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_die();
return ent2ncr( $export );
}

/**
* Build a CSV export of all submissions.
*
* @return string
*/
private function export_csv() {
$records = get_posts(
array(
'post_type' => Form_Submissions::FORM_RECORD_TYPE,
'post_status' => array( 'draft', 'unread', 'read', 'trash', 'publish' ),
'posts_per_page' => -1,
'orderby' => 'ID',
'order' => 'ASC',
)
);

update_meta_cache( 'post', wp_list_pluck( $records, 'ID' ) );

$fixed_columns = array(
'id' => __( 'ID', 'otter-blocks' ),
'status' => __( 'Status', 'otter-blocks' ),
'date' => __( 'Submission Date', 'otter-blocks' ),
'form' => __( 'Form', 'otter-blocks' ),
'post' => __( 'Post URL', 'otter-blocks' ),
);

$input_columns = array();
$rows = array();

foreach ( $records as $record ) {
$post_id = $record->ID;
$meta = get_post_meta( $post_id, Form_Submissions::FORM_RECORD_META_KEY, true );

if ( ! is_array( $meta ) ) {
continue;
}

$row = array(
'id' => substr( strval( $post_id ), -8 ),
'status' => get_post_status( $post_id ),
'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post_id ),
'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '',
'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '',
);

$inputs = isset( $meta['inputs'] ) && is_array( $meta['inputs'] ) ? $meta['inputs'] : array();

foreach ( $inputs as $input ) {
if ( empty( $input ) || ! isset( $input['type'], $input['label'] ) || 'stripe-field' === $input['type'] ) {
continue;
}

$label = $input['label'];
$column_key = 'input:' . $label;

if ( ! isset( $input_columns[ $label ] ) ) {
$input_columns[ $column_key ] = $label;
}

$value = isset( $input['value'] ) ? $input['value'] : '';

if ( 'file' === $input['type'] && isset( $input['metadata']['name'] ) ) {
$value = $input['metadata']['name'];
}

$row[ $column_key ] = $value;
}

$rows[] = $row;
}

$columns = array_merge( $fixed_columns, $input_columns );

$stream = fopen( 'php://temp', 'w+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen

$sanitize_cell = static function ( $value ) {
$value = strval( $value );
return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value;
};

fputcsv( $stream, array_map( $sanitize_cell, array_values( $columns ) ) ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv

foreach ( $rows as $row ) {
$line = array();

foreach ( array_keys( $columns ) as $key ) {
$value = isset( $row[ $key ] ) ? $row[ $key ] : '';
$line[] = $sanitize_cell( $value );
}

fputcsv( $stream, $line ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fputcsv
}

rewind( $stream );
$csv = stream_get_contents( $stream );
fclose( $stream );

// Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters.
return "\xEF\xBB\xBF" . $csv;
}
}
19 changes: 17 additions & 2 deletions src/blocks/test/e2e/blocks/form.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,10 @@ test.describe( 'Form Block', () => {
await expect( page.locator( '.otter-form-input[type="hidden"]' ) ).toHaveValue( '123' );
});

test( 'can export form data', async({ page }) => {
test( 'can export form data as WordPress XML (WXR)', async({ page }) => {
await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' );

const exportBtn = page.getByRole( 'button', { name: 'Export' });
const exportBtn = page.getByRole( 'button', { name: 'Export', exact: true });

await expect( exportBtn ).toBeVisible();

Expand All @@ -425,5 +425,20 @@ test.describe( 'Form Block', () => {

await download.path(); // Wait for download to complete.
expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy();
expect( download.suggestedFilename().endsWith( '.xml' ) ).toBeTruthy();
});

test( 'can export form data as CSV', async({ page }) => {
await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' );

await page.locator( '#export-submissions-toggle' ).click();

const downloadPromise = page.waitForEvent( 'download' );
await page.getByRole( 'button', { name: 'Export as CSV' }).click();
const download = await downloadPromise;

await download.path(); // Wait for download to complete.
expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy();
expect( download.suggestedFilename().endsWith( '.csv' ) ).toBeTruthy();
});
});
Loading
Loading