Skip to content

Commit

Permalink
Craft.downloadFromUrl() + better element exporting
Browse files Browse the repository at this point in the history
Resolves #5558
  • Loading branch information
brandonkelly committed Feb 5, 2020
1 parent a43b6d9 commit b6bd1c4
Show file tree
Hide file tree
Showing 15 changed files with 249 additions and 92 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
- Added the ability to limit multiple selections in admin tables.
- Added an event to admin tables when selections are changed.
- Added an event to admin tables to retrieve currently visible data.
- Added `craft\controllers\ElementIndexesController::actionExport()`.
- Added the `Craft.downloadFromUrl()` JavaScript method.

### Deprecated
- Deprecated `craft\controllers\ElementIndexesController::actionCreateExportToken()`.
- Deprecated `craft\controllers\ExportController`.

### Fixed
- Fixed a bug where data tables weren’t getting horizontal scrollbars in Firefox. ([#5574](https://github.com/craftcms/cms/issues/5574))
Expand All @@ -15,6 +21,7 @@
- Fixed a bug where the `_count` field would sometimes not work correctly when using GraphQL. ([#4847](https://github.com/craftcms/cms/issues/4847))
- Fixed a bug where assets that had been drag-uploaded to an Assets field would be hyperlinked. ([#5584](https://github.com/craftcms/cms/issues/5584))
- Fixed a bug where `CustomFieldBehavior.php` was getting created with restricted permissions. ([#5570](https://github.com/craftcms/cms/issues/5570))
- Fixed a bug where element exporting would redirect the browser window if the export request didn’t immediately return the export data. ([#5558](https://github.com/craftcms/cms/issues/5558))

## 3.4.3 - 2020-02-03

Expand Down
1 change: 0 additions & 1 deletion src/controllers/BaseElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ abstract class BaseElementsController extends Controller
*/
public function init()
{
$this->requireAcceptsJson();
$this->requireCpRequest();
parent::init();
}
Expand Down
14 changes: 14 additions & 0 deletions src/controllers/ElementIndexSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@
*/
class ElementIndexSettingsController extends BaseElementsController
{
/**
* @inheritdoc
*/
public function beforeAction($action)
{
if (!parent::beforeAction($action)) {
return false;
}

$this->requireAcceptsJson();

return true;
}

/**
* Returns all the info needed by the Customize Sources modal.
*
Expand Down
97 changes: 71 additions & 26 deletions src/controllers/ElementIndexesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ public function beforeAction($action)
return false;
}

if ($action->id !== 'export') {
$this->requireAcceptsJson();
}

$request = Craft::$app->getRequest();
$this->elementType = $this->elementType();
$this->context = $this->context();
Expand Down Expand Up @@ -257,50 +261,62 @@ public function actionGetSourceTreeHtml(): Response
]);
}

/**
* Exports element data.
*
* @return Response
* @throws BadRequestHttpException
* @since 3.4.4
*/
public function actionExport(): Response
{
$exporter = $this->_exporter();
$exporter->setElementType($this->elementType);

$request = Craft::$app->getRequest();
$response = Craft::$app->getResponse();

$response->data = $exporter->export($this->elementQuery);
$response->format = $request->getBodyParam('format', 'csv');
$response->setDownloadHeaders($exporter->getFilename() . ".{$response->format}");

switch ($response->format) {
case Response::FORMAT_JSON:
$response->formatters[Response::FORMAT_JSON]['prettyPrint'] = true;
break;
case Response::FORMAT_XML:
Craft::$app->language = 'en-US';
/** @var string|ElementInterface $elementType */
$elementType = $this->elementType;
$response->formatters[Response::FORMAT_XML]['rootTag'] = $elementType::pluralLowerDisplayName();
$response->formatters[Response::FORMAT_XML]['itemTag'] = $elementType::lowerDisplayName();
break;
}

return $response;
}

/**
* Creates an export token.
*
* @return Response
* @throws BadRequestHttpException
* @throws ServerErrorHttpException
* @since 3.2.0
* @deprecated in 3.4.4
*/
public function actionCreateExportToken(): Response
{
if (!$this->sourceKey) {
throw new BadRequestHttpException('Request missing required body param');
}

if ($this->context !== 'index') {
throw new BadRequestHttpException('Request missing index context');
}

$exporter = $this->_exporter();
$request = Craft::$app->getRequest();

// Find that exporter from the list of available exporters for the source
$exporterClass = $request->getBodyParam('type', Raw::class);
if (!empty($this->exporters)) {
foreach ($this->exporters as $availableExporter) {
/** @var ElementAction $availableExporter */
if ($exporterClass === get_class($availableExporter)) {
$exporter = $availableExporter;
break;
}
}
}

/** @noinspection UnSafeIsSetOverArrayInspection - FP */
if (!isset($exporter)) {
throw new BadRequestHttpException('Element exporter is not supported by the element type');
}

$token = Craft::$app->getTokens()->createToken([
'export/export',
[
'elementType' => $this->elementType,
'sourceKey' => $this->sourceKey,
'criteria' => $request->getBodyParam('criteria', []),
'exporter' => $exporterClass,
'exporter' => get_class($exporter),
'format' => $request->getBodyParam('format', 'csv'),
]
], 1, (new \DateTime())->add(new \DateInterval('PT1H')));
Expand All @@ -312,6 +328,35 @@ public function actionCreateExportToken(): Response
return $this->asJson(compact('token'));
}

/**
* Returns the exporter for the request.
*
* @throws BadRequestHttpException
* @return ElementExporterInterface
*/
private function _exporter(): ElementExporterInterface
{
if (!$this->sourceKey) {
throw new BadRequestHttpException('Request missing required body param');
}

if ($this->context !== 'index') {
throw new BadRequestHttpException('Request missing index context');
}

// Find that exporter from the list of available exporters for the source
$exporterClass = Craft::$app->getRequest()->getBodyParam('type', Raw::class);
if (!empty($this->exporters)) {
foreach ($this->exporters as $exporter) {
if ($exporterClass === get_class($exporter)) {
return $exporter;
}
}
}

throw new BadRequestHttpException('Element exporter is not supported by the element type');
}

/**
* Identify whether index actions should be included in the element index
*
Expand Down
14 changes: 14 additions & 0 deletions src/controllers/ElementsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
*/
class ElementsController extends BaseElementsController
{
/**
* @inheritdoc
*/
public function beforeAction($action)
{
if (!parent::beforeAction($action)) {
return false;
}

$this->requireAcceptsJson();

return true;
}

/**
* Renders and returns the body of an ElementSelectorModal.
*
Expand Down
1 change: 1 addition & 0 deletions src/controllers/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 3.2.0
* @deprecated in 3.4.4
*/
class ExportController extends Controller
{
Expand Down
1 change: 1 addition & 0 deletions src/templates/_components/utilities/DbBackup.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

{{ forms.checkbox({
name: 'downloadBackup',
id: 'download-backup',
label: 'Download backup?'|t('app'),
checked: true,
}) }}
Expand Down
72 changes: 59 additions & 13 deletions src/web/assets/cp/dist/js/Craft.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*! - 2020-01-31 */
/*! - 2020-02-05 */
(function($){

/** global: Craft */
Expand Down Expand Up @@ -612,6 +612,55 @@ $.extend(Craft,
}.bind(this));
},

/**
* Requests a URL and downloads the response.
*
* @param {string} method the request method to use
* @param {string} url the URL
* @param {string|Object} [body] the request body, if method = POST
* @return {Promise}
*/
downloadFromUrl: function(method, url, body) {
return new Promise((resolve, reject) => {
// h/t https://nehalist.io/downloading-files-from-post-requests/
let request = new XMLHttpRequest();
request.open(method, url, true);
if (typeof body === 'object') {
request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
body = JSON.stringify(body);
} else {
request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
request.responseType = 'blob';

request.onload = function() {
// Only handle status code 200
if (request.status === 200) {
// Try to find out the filename from the content disposition `filename` value
let disposition = request.getResponseHeader('content-disposition');
let matches = /"([^"]*)"/.exec(disposition);
let filename = (matches != null && matches[1] ? matches[1] : 'Download');

// Encode the download into an anchor href
let contentType = request.getResponseHeader('content-type');
let blob = new Blob([request.response], {type: contentType});
let link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

resolve();
} else {
reject();
}
}.bind(this);

request.send(body);
});
},

/**
* Converts a comma-delimited string into an array.
*
Expand Down Expand Up @@ -3864,19 +3913,16 @@ Craft.BaseElementIndex = Garnish.Base.extend(
}
}

Craft.postActionRequest('element-indexes/create-export-token', params, $.proxy(function(response, textStatus) {
submitting = false;
$spinner.addClass('hidden');

if (textStatus === 'success') {
var params = {};
params[Craft.tokenParam] = response.token;
var url = Craft.getCpUrl('', params);
document.location.href = url;
} else {
Craft.downloadFromUrl('POST', Craft.getActionUrl('element-indexes/export'), params)
.then(function() {
submitting = false;
$spinner.addClass('hidden');
})
.catch(function() {
submitting = false;
$spinner.addClass('hidden');
Craft.cp.displayError(Craft.t('app', 'A server error occurred.'));
}
}, this));
});
});
},

Expand Down
2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/js/Craft.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/cp/dist/js/Craft.min.js.map

Large diffs are not rendered by default.

21 changes: 9 additions & 12 deletions src/web/assets/cp/src/js/BaseElementIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -1815,19 +1815,16 @@ Craft.BaseElementIndex = Garnish.Base.extend(
}
}

Craft.postActionRequest('element-indexes/create-export-token', params, $.proxy(function(response, textStatus) {
submitting = false;
$spinner.addClass('hidden');

if (textStatus === 'success') {
var params = {};
params[Craft.tokenParam] = response.token;
var url = Craft.getCpUrl('', params);
document.location.href = url;
} else {
Craft.downloadFromUrl('POST', Craft.getActionUrl('element-indexes/export'), params)
.then(function() {
submitting = false;
$spinner.addClass('hidden');
})
.catch(function() {
submitting = false;
$spinner.addClass('hidden');
Craft.cp.displayError(Craft.t('app', 'A server error occurred.'));
}
}, this));
});
});
},

Expand Down
49 changes: 49 additions & 0 deletions src/web/assets/cp/src/js/Craft.js
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,55 @@ $.extend(Craft,
}.bind(this));
},

/**
* Requests a URL and downloads the response.
*
* @param {string} method the request method to use
* @param {string} url the URL
* @param {string|Object} [body] the request body, if method = POST
* @return {Promise}
*/
downloadFromUrl: function(method, url, body) {
return new Promise((resolve, reject) => {
// h/t https://nehalist.io/downloading-files-from-post-requests/
let request = new XMLHttpRequest();
request.open(method, url, true);
if (typeof body === 'object') {
request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
body = JSON.stringify(body);
} else {
request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
}
request.responseType = 'blob';

request.onload = function() {
// Only handle status code 200
if (request.status === 200) {
// Try to find out the filename from the content disposition `filename` value
let disposition = request.getResponseHeader('content-disposition');
let matches = /"([^"]*)"/.exec(disposition);
let filename = (matches != null && matches[1] ? matches[1] : 'Download');

// Encode the download into an anchor href
let contentType = request.getResponseHeader('content-type');
let blob = new Blob([request.response], {type: contentType});
let link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

resolve();
} else {
reject();
}
}.bind(this);

request.send(body);
});
},

/**
* Converts a comma-delimited string into an array.
*
Expand Down
Loading

0 comments on commit b6bd1c4

Please sign in to comment.