diff --git a/application/Espo/Controllers/Export.php b/application/Espo/Controllers/Export.php index 4e93cebca4..6b43285809 100644 --- a/application/Espo/Controllers/Export.php +++ b/application/Espo/Controllers/Export.php @@ -43,21 +43,15 @@ use stdClass; class Export { - private $service; - - public function __construct(Service $service) - { - $this->service = $service; - } + public function __construct(private Service $service) + {} public function postActionProcess(Request $request): stdClass { $params = $this->fetchRawParamsFromRequest($request); $serviceParams = ServiceParams::create() - ->withIsIdle( - $request->getParsedBody()->idle ?? false - ); + ->withIsIdle($request->getParsedBody()->idle ?? false); $result = $this->service->process($params, $serviceParams); @@ -141,6 +135,14 @@ class Export $params['format'] = $data->format; } - return Params::fromRaw($params); + $obj = Params::fromRaw($params); + + if (isset($data->params) && $data->params instanceof stdClass) { + foreach (get_object_vars($data->params) as $key => $value) { + $obj = $obj->withParam($key, $value); + } + } + + return $obj; } } diff --git a/application/Espo/Resources/i18n/en_US/Export.json b/application/Espo/Resources/i18n/en_US/Export.json index c0105e92bd..e88ad18de5 100644 --- a/application/Espo/Resources/i18n/en_US/Export.json +++ b/application/Espo/Resources/i18n/en_US/Export.json @@ -3,7 +3,9 @@ "exportAllFields": "Export all fields", "fieldList": "Field List", "format": "Format", - "status": "Status" + "status": "Status", + "xlsxRecordLinks": "Record Links", + "xlsxTitleRow": "Title Row" }, "options": { "format": { @@ -17,6 +19,9 @@ "Failed": "Failed" } }, + "tooltips": { + "xlsxTitleRow": "Title and date in the first row." + }, "messages": { "exportProcessed": "Export has been processed. Download the [file]({url}).", "infoText": "The export is being processed in idle by cron. It can take some time to finish. Closing this modal dialog won't affect the execution process." diff --git a/application/Espo/Resources/metadata/app/export.json b/application/Espo/Resources/metadata/app/export.json index b55facfa58..4042cff0f2 100644 --- a/application/Espo/Resources/metadata/app/export.json +++ b/application/Espo/Resources/metadata/app/export.json @@ -37,6 +37,25 @@ "enum": "Espo\\Tools\\Export\\Format\\Xlsx\\CellValuePreparators\\Enumeration", "multiEnum": "Espo\\Tools\\Export\\Format\\Xlsx\\CellValuePreparators\\MultiEnum", "array": "Espo\\Tools\\Export\\Format\\Xlsx\\CellValuePreparators\\MultiEnum" + }, + "params": { + "fields": { + "recordLinks": { + "type": "bool", + "default": false + }, + "titleRow": { + "type": "bool", + "default": true, + "tooltip": true + } + }, + "layout": [ + [ + {"name": "recordLinks"}, + {"name": "titleRow"} + ] + ] } } } diff --git a/application/Espo/Tools/Export/Export.php b/application/Espo/Tools/Export/Export.php index b25126edc3..ff6f17a67d 100644 --- a/application/Espo/Tools/Export/Export.php +++ b/application/Espo/Tools/Export/Export.php @@ -176,9 +176,15 @@ class Export $fileName . '.' . $fileExtension : "Export_{$entityType}.{$fileExtension}"; - return (new ProcessorParams($fileName, $attributeList, $fieldList)) + $processorParams = (new ProcessorParams($fileName, $attributeList, $fieldList)) ->withName($params->getName()) ->withEntityType($params->getEntityType()); + + foreach ($params->getParamList() as $n) { + $processorParams = $processorParams->withParam($n, $params->getParam($n)); + } + + return $processorParams; } private function getForeignAttributeType(Entity $entity, string $attribute): ?string diff --git a/application/Espo/Tools/Export/Format/Xlsx/Processor.php b/application/Espo/Tools/Export/Format/Xlsx/Processor.php index 3f9beb3eb3..57c3c1076f 100644 --- a/application/Espo/Tools/Export/Format/Xlsx/Processor.php +++ b/application/Espo/Tools/Export/Format/Xlsx/Processor.php @@ -66,6 +66,8 @@ use RuntimeException; class Processor implements ProcessorInterface { private const FORMAT = 'xlsx'; + private const PARAM_RECORD_LINKS = 'recordLinks'; + private const PARAM_TITLE_ROW = 'titleRow'; /** @var array */ private array $preparatorsCache = []; @@ -134,20 +136,22 @@ class Processor implements ProcessorInterface $now = new DateTime(); $now->setTimezone(new DateTimeZone($this->config->get('timeZone', 'UTC'))); - $sheet->setCellValue('A1', $this->sanitizeCellValue($exportName)); - $sheet->setCellValue('B1', - SharedDate::PHPToExcel(strtotime($now->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT))) - ); + if ($params->getParam(self::PARAM_TITLE_ROW)) { + $sheet->setCellValue('A1', $this->sanitizeCellValue($exportName)); + $sheet->setCellValue('B1', + SharedDate::PHPToExcel(strtotime($now->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT))) + ); - $sheet->getStyle('A1')->applyFromArray($this->titleStyle); - $sheet->getStyle('B1')->applyFromArray($this->dateStyle); - $sheet->getStyle('B1') - ->getNumberFormat() - ->setFormatCode($this->dateTime->getDateTimeFormat()); + $sheet->getStyle('A1')->applyFromArray($this->titleStyle); + $sheet->getStyle('B1')->applyFromArray($this->dateStyle); + $sheet->getStyle('B1') + ->getNumberFormat() + ->setFormatCode($this->dateTime->getDateTimeFormat()); + } $azRange = $this->getColumnsRange($fieldList); - $rowNumber = 3; + $rowNumber = $params->getParam(self::PARAM_TITLE_ROW) ? 3 : 1; $linkColList = []; $lastIndex = 0; @@ -176,9 +180,13 @@ class Processor implements ProcessorInterface $sheet->setCellValue($col . $rowNumber, $this->sanitizeCellValue($label)); $sheet->getColumnDimension($col)->setAutoSize(true); + $linkTypeList = $params->getParam(self::PARAM_RECORD_LINKS) ? + ['url', 'phone', 'email', 'link', 'linkParent'] : + ['url']; + if ( - in_array($type, ['phone', 'email', 'url', 'link', 'linkParent']) || - $name === 'name' + in_array($type, $linkTypeList) || + $params->getParam(self::PARAM_RECORD_LINKS) && $name === 'name' ) { $linkColList[] = $col; } diff --git a/application/Espo/Tools/Export/Params.php b/application/Espo/Tools/Export/Params.php index d2b5190666..e38615aa85 100644 --- a/application/Espo/Tools/Export/Params.php +++ b/application/Espo/Tools/Export/Params.php @@ -40,19 +40,16 @@ use RuntimeException; class Params { private string $entityType; - /** - * @var ?string[] - */ + /** @var ?string[] */ private $attributeList = null; - /** - * @var ?string[] - */ + /** @var ?string[] */ private $fieldList = null; private ?string $fileName = null; private ?string $format = null; private ?string $name = null; + /** @var array */ + private array $params = []; private ?SearchParams $searchParams = null; - private bool $applyAccessControl = true; public function __construct(string $entityType) @@ -61,7 +58,7 @@ class Params } /** - * @param array $params + * @param array $params * @throws RuntimeException */ public static function fromRaw(array $params): self @@ -144,7 +141,6 @@ class Params public function withFormat(?string $format): self { $obj = clone $this; - $obj->format = $format; return $obj; @@ -153,7 +149,6 @@ class Params public function withFileName(?string $fileName): self { $obj = clone $this; - $obj->fileName = $fileName; return $obj; @@ -162,7 +157,6 @@ class Params public function withName(?string $name): self { $obj = clone $this; - $obj->name = $name; return $obj; @@ -171,19 +165,25 @@ class Params public function withSearchParams(?SearchParams $searchParams): self { $obj = clone $this; - $obj->searchParams = $searchParams; return $obj; } + public function withParam(string $name, mixed $value): self + { + $obj = clone $this; + $obj->params[$name] = $value; + + return $obj; + } + /** * @param ?string[] $fieldList */ public function withFieldList(?array $fieldList): self { $obj = clone $this; - $obj->fieldList = $fieldList; return $obj; @@ -195,7 +195,6 @@ class Params public function withAttributeList(?array $attributeList): self { $obj = clone $this; - $obj->attributeList = $attributeList; return $obj; @@ -204,7 +203,6 @@ class Params public function withAccessControl(bool $applyAccessControl = true): self { $obj = clone $this; - $obj->applyAccessControl = $applyAccessControl; return $obj; @@ -274,6 +272,32 @@ class Params return $this->fieldList; } + /** + * Get a parameter list. + * + * @return string[] + */ + public function getParamList(): array + { + return array_keys($this->params); + } + + /** + * Get a parameter value. + */ + public function getParam(string $name): mixed + { + return $this->params[$name] ?? null; + } + + /** + * Has a parameter. + */ + public function hasParam(string $name): bool + { + return array_key_exists($name, $this->params); + } + /** * Whether all fields should be exported. */ diff --git a/application/Espo/Tools/Export/Processor/Params.php b/application/Espo/Tools/Export/Processor/Params.php index 458c1777be..af8e5ca7f0 100644 --- a/application/Espo/Tools/Export/Processor/Params.php +++ b/application/Espo/Tools/Export/Processor/Params.php @@ -43,6 +43,8 @@ class Params private ?array $fieldList = null; private ?string $name = null; private ?string $entityType = null; + /** @var array */ + private array $params = []; /** * @param string[] $attributeList @@ -93,6 +95,14 @@ class Params return $obj; } + public function withParam(string $name, mixed $value): self + { + $obj = clone $this; + $obj->params[$name] = $value; + + return $obj; + } + /** * An export file name. */ @@ -140,4 +150,12 @@ class Params return $this->entityType; } + + /** + * Get a parameter value. + */ + public function getParam(string $name): mixed + { + return $this->params[$name] ?? null; + } } diff --git a/application/Espo/Tools/Export/Service.php b/application/Espo/Tools/Export/Service.php index d00b6a65ab..d6a7a24113 100644 --- a/application/Espo/Tools/Export/Service.php +++ b/application/Espo/Tools/Export/Service.php @@ -31,18 +31,14 @@ namespace Espo\Tools\Export; use Espo\Core\Exceptions\ForbiddenSilent; use Espo\Core\Exceptions\NotFoundSilent; - use Espo\Core\Acl; use Espo\Core\Acl\Table; use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata; use Espo\Core\Job\JobSchedulerFactory; use Espo\Core\Job\Job\Data as JobData; - use Espo\Tools\Export\Jobs\Process; - use Espo\ORM\EntityManager; - use Espo\Entities\Export as ExportEntity; use Espo\Entities\User; @@ -50,37 +46,15 @@ use stdClass; class Service { - private Factory $factory; - - private Config $config; - - private Acl $acl; - - private User $user; - - private Metadata $metadata; - - private EntityManager $entityManager; - - private JobSchedulerFactory $jobSchedulerFactory; - public function __construct( - Factory $factory, - Config $config, - Acl $acl, - User $user, - Metadata $metadata, - EntityManager $entityManager, - JobSchedulerFactory $jobSchedulerFactory - ) { - $this->factory = $factory; - $this->config = $config; - $this->acl = $acl; - $this->user = $user; - $this->metadata = $metadata; - $this->entityManager = $entityManager; - $this->jobSchedulerFactory = $jobSchedulerFactory; - } + private Factory $factory, + private Config $config, + private Acl $acl, + private User $user, + private Metadata $metadata, + private EntityManager $entityManager, + private JobSchedulerFactory $jobSchedulerFactory + ) {} public function process(Params $params, ServiceParams $serviceParams): ServiceResult { @@ -121,8 +95,8 @@ class Service public function getStatusData(string $id): stdClass { - /** @var ExportEntity|null $entity */ - $entity = $this->entityManager->getEntity(ExportEntity::ENTITY_TYPE, $id); + /** @var ?ExportEntity $entity */ + $entity = $this->entityManager->getEntityById(ExportEntity::ENTITY_TYPE, $id); if (!$entity) { throw new NotFoundSilent(); @@ -140,8 +114,8 @@ class Service public function subscribeToNotificationOnSuccess(string $id): void { - /** @var ExportEntity|null $entity */ - $entity = $this->entityManager->getEntity(ExportEntity::ENTITY_TYPE, $id); + /** @var ?ExportEntity $entity */ + $entity = $this->entityManager->getEntityById(ExportEntity::ENTITY_TYPE, $id); if (!$entity) { throw new NotFoundSilent(); diff --git a/client/res/templates/export/modals/export.tpl b/client/res/templates/export/modals/export.tpl index 24aaca0d1c..789d9698d4 100644 --- a/client/res/templates/export/modals/export.tpl +++ b/client/res/templates/export/modals/export.tpl @@ -1,5 +1 @@ -
-
-
{{{record}}}
-
-
\ No newline at end of file +
{{{record}}}
diff --git a/client/res/templates/export/record/record.tpl b/client/res/templates/export/record/record.tpl deleted file mode 100644 index 1a36e96252..0000000000 --- a/client/res/templates/export/record/record.tpl +++ /dev/null @@ -1,14 +0,0 @@ -
- -
{{{formatField}}}
-
- -
- -
{{{exportAllFieldsField}}}
-
- -
- -
{{{fieldListField}}}
-
diff --git a/client/src/model.js b/client/src/model.js index 7c093f8029..7ee76d3459 100644 --- a/client/src/model.js +++ b/client/src/model.js @@ -248,7 +248,10 @@ define('model', [], function () { /** * Set defs. * - * @param {Object} defs + * @param {{ + * fields?: Object., + * links?: Object., + * }} defs */ setDefs: function (defs) { this.defs = defs || {}; diff --git a/client/src/views/export/modals/export.js b/client/src/views/export/modals/export.js index 32155544ca..4f995e9a1b 100644 --- a/client/src/views/export/modals/export.js +++ b/client/src/views/export/modals/export.js @@ -70,7 +70,7 @@ define('views/export/modals/export', ['views/modal', 'model'], function (Dep, Mo this.model.set('exportAllFields', true); } - var formatList = + let formatList = this.getMetadata().get(['scopes', this.scope, 'exportFormatList']) || this.getMetadata().get('app.export.formatList'); @@ -80,52 +80,80 @@ define('views/export/modals/export', ['views/modal', 'model'], function (Dep, Mo scope: this.scope, model: this.model, el: this.getSelector() + ' .record', + formatList: formatList, }); }, + getRecordView: function () { + return this.getView('record'); + }, + actionExport: function () { - var data = this.getView('record').fetch(); + let recordView = this.getRecordView(); + + let data = recordView.fetch(); + this.model.set(data); - if (this.getView('record').validate()) { + if (recordView.validate()) { return; } - var returnData = { + let returnData = { exportAllFields: data.exportAllFields, format: data.format, }; if (!data.exportAllFields) { - var attributeList = []; + let attributeList = []; - data.fieldList.forEach(function (item) { + data.fieldList.forEach(item => { if (item === 'id') { attributeList.push('id'); return; } - var type = this.getMetadata().get(['entityDefs', this.scope, 'fields', item, 'type']); + let type = this.getMetadata().get(['entityDefs', this.scope, 'fields', item, 'type']); if (type) { - this.getFieldManager().getAttributeList(type, item).forEach(function (attribute) { - attributeList.push(attribute); - }, this); + this.getFieldManager().getAttributeList(type, item) + .forEach(attribute => { + attributeList.push(attribute); + }); } if (~item.indexOf('_')) { attributeList.push(item); } - }, this); + }); returnData.attributeList = attributeList; returnData.fieldList = data.fieldList; } + returnData.params = {}; + + recordView.getFormatParamList(data.format).forEach(param => { + let name = recordView.modifyParamName(data.format, param); + + let fieldView = recordView.getFieldView(name); + + if (!fieldView || fieldView.disabled) { + return; + } + + this.getFieldManager() + .getActualAttributeList(fieldView.type, param) + .forEach(subParam => { + let name = recordView.modifyParamName(data.format, subParam); + + returnData.params[subParam] = data[name]; + }); + }); + this.trigger('proceed', returnData); this.close(); }, - }); }); diff --git a/client/src/views/export/record/record.js b/client/src/views/export/record/record.js index 5bf02233fa..7cb4fad0d4 100644 --- a/client/src/views/export/record/record.js +++ b/client/src/views/export/record/record.js @@ -26,51 +26,226 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -define('views/export/record/record', 'views/record/base', function (Dep) { +define('views/export/record/record', ['views/record/edit-for-modal'], function (Dep) { - return Dep.extend({ + /** + * @class + * @name Class + * @memberOf module:views/export/record/record + * @extends module:views/record/edit-for-modal.Class + */ + return Dep.extend(/** @lends module:views/export/record/record.Class# */{ - template: 'export/record/record', + /** + * @type {string[]}, + */ + formatList: null, + + /** + * @type {Object.}, + */ + customParams: null, setup: function () { Dep.prototype.setup.call(this); + }, + setupBeforeFinal: function () { + this.formatList = this.options.formatList; this.scope = this.options.scope; - var fieldList = this.getFieldManager().getEntityTypeFieldList(this.scope); + let fieldsData = this.getExportFieldsData(); - var forbiddenFieldList = this.getAcl().getScopeForbiddenFieldList(this.scope); + this.setupExportFieldDefs(fieldsData); + this.setupExportLayout(fieldsData); + this.setupExportDynamicLogic(); - fieldList = fieldList.filter(function (item) { + this.controlFormatField(); + this.listenTo(this.model, 'change:format', () => this.controlFormatField()); + + this.controlAllFields(); + this.listenTo(this.model, 'change:exportAllFields', () => this.controlAllFields()); + + Dep.prototype.setupBeforeFinal.call(this); + }, + + setupExportFieldDefs: function (fieldsData) { + let fieldDefs = { + format: { + type: 'enum', + options: this.formatList, + }, + fieldList: { + type: 'multiEnum', + options: fieldsData.list, + required: true, + }, + exportAllFields: { + type: 'bool', + }, + }; + + this.customParams = {}; + + this.formatList.forEach(format => { + let fields = this.getFormatParamsDefs(format).fields || {}; + + this.customParams[format] = []; + + for (let name in fields) { + let newName = this.modifyParamName(format, name); + + this.customParams[format].push(name); + + fieldDefs[newName] = Espo.Utils.cloneDeep(fields[name]); + } + }); + + this.model.setDefs({fields: fieldDefs}); + }, + + setupExportLayout: function (fieldsData) { + this.detailLayout = []; + + let mainPanel = { + rows: [ + [ + {name: 'format'}, + false + ], + [ + {name: 'exportAllFields'}, + false + ], + [ + { + name: 'fieldList', + options: { + translatedOptions: fieldsData.translations, + }, + } + ], + ] + }; + + this.detailLayout.push(mainPanel); + + this.formatList.forEach(format => { + let rows = this.getFormatParamsDefs(format).layout || []; + + rows.forEach(row => { + row.forEach(item => { + item.name = this.modifyParamName(format, item.name); + }); + }) + + this.detailLayout.push({ + name: format, + rows: rows, + }) + }); + }, + + setupExportDynamicLogic: function () { + this.dynamicLogicDefs = { + fields: {}, + }; + + this.formatList.forEach(format => { + let defs = this.getFormatParamsDefs(format).dynamicLogic || {}; + + this.customParams[format].forEach(param => { + let logic = defs[param] || {}; + + if (!logic.visible) { + logic.visible = {}; + } + + if (!logic.visible.conditionGroup) { + logic.visible.conditionGroup = []; + } + + logic.visible.conditionGroup.push({ + type: 'equals', + attribute: 'format', + value: format, + }); + + let newName = this.modifyParamName(format, param); + + this.dynamicLogicDefs.fields[newName] = logic; + }); + }); + }, + + /** + * @param {string} format + * @return {string[]} + */ + getFormatParamList: function (format) { + return Object.keys(this.getFormatParamsDefs(format).fields || {}); + }, + + /** + * @private + * @return {Object.} + */ + getFormatParamsDefs: function (format) { + let defs = this.getMetadata().get(['app', 'export', 'formatDefs', format]) || {}; + + return Espo.Utils.cloneDeep(defs.params || {}); + }, + + /** + * @param {string} format + * @param {string} name + * @return {string} + */ + modifyParamName: function (format, name) { + return format + Espo.Utils.upperCaseFirst(name); + }, + + /** + * @return {{ + * translations: Object., + * list: string[] + * }} + */ + getExportFieldsData: function () { + let fieldList = this.getFieldManager().getEntityTypeFieldList(this.scope); + let forbiddenFieldList = this.getAcl().getScopeForbiddenFieldList(this.scope); + + fieldList = fieldList.filter(item => { return !~forbiddenFieldList.indexOf(item); - }, this); + }); + fieldList = fieldList.filter(item => { + let defs = this.getMetadata().get(['entityDefs', this.scope, 'fields', item]) || {}; - fieldList = fieldList.filter(function (item) { - var defs = this.getMetadata().get(['entityDefs', this.scope, 'fields', item]) || {}; - - if (defs.disabled) return; - if (defs.exportDisabled) return; - if (defs.type === 'map') return; + if ( + defs.disabled || + defs.exportDisabled || + defs.type === 'map' + ) { + return false + } return true; - }, this); + }); this.getLanguage().sortFieldList(this.scope, fieldList); fieldList.unshift('id'); - var translatedOptions = {}; + let fieldListTranslations = {}; - fieldList.forEach(function (item) { - translatedOptions[item] = this.getLanguage().translate(item, 'fields', this.scope); - }, this); + fieldList.forEach(item => { + fieldListTranslations[item] = this.getLanguage().translate(item, 'fields', this.scope); + }); - this.createField('exportAllFields', 'views/fields/bool', {}); + let setFieldList = this.model.get('fieldList') || []; - var setFieldList = this.model.get('fieldList') || []; - - setFieldList.forEach(function (item) { + setFieldList.forEach(item => { if (~fieldList.indexOf(item)) { return; } @@ -79,49 +254,52 @@ define('views/export/record/record', 'views/record/base', function (Dep) { return; } - var arr = item.split('_'); + let arr = item.split('_'); fieldList.push(item); - var foreignScope = this.getMetadata().get(['entityDefs', this.scope, 'links', arr[0], 'entity']); + let foreignScope = this.getMetadata().get(['entityDefs', this.scope, 'links', arr[0], 'entity']); if (!foreignScope) { return; } - translatedOptions[item] = this.getLanguage().translate(arr[0], 'links', this.scope) + '.' + + fieldListTranslations[item] = this.getLanguage().translate(arr[0], 'links', this.scope) + '.' + this.getLanguage().translate(arr[1], 'fields', foreignScope); - }, this); - - - this.createField('fieldList', 'views/fields/multi-enum', { - required: true, - translatedOptions: translatedOptions, - options: fieldList, }); - var formatList = - this.getMetadata().get(['scopes', this.scope, 'exportFormatList']) || - this.getMetadata().get('app.export.formatList'); - - this.createField('format', 'views/fields/enum', { - options: formatList - }); - - this.controlAllFields(); - - this.listenTo(this.model, 'change:exportAllFields', function () { - this.controlAllFields(); - }, this); + return { + list: fieldList, + translations: fieldListTranslations, + }; }, controlAllFields: function () { if (!this.model.get('exportAllFields')) { this.showField('fieldList'); - } else { - this.hideField('fieldList'); + + return; } + + this.hideField('fieldList'); }, + controlFormatField: function () { + let format = this.model.get('format'); + + this.formatList + .filter(item => item !== format) + .forEach(format => { + this.hidePanel(format); + }); + + this.formatList + .filter(item => item === format) + .forEach(format => { + this.customParams[format].length ? + this.showPanel(format) : + this.hidePanel(format); + }); + }, }); }); diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index 88656fc30c..a43bd72da9 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -1148,6 +1148,7 @@ function (Dep, MassActionHelper, ExportHelper, RecordModal) { data.idle = idle; data.format = dialogData.format; + data.params = dialogData.params; Espo.Ui.notify(this.translate('pleaseWait', 'messages'));