From 3fd33d32697cc3f4f9bae56c32a70c9533c17b78 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 16 Apr 2025 20:46:17 +0300 Subject: [PATCH] view stream attachments --- .../Espo/Resources/i18n/en_US/Note.json | 1 + .../layouts/Attachment/listForStream.json | 8 ++ application/Espo/Resources/routes.json | 5 + .../Espo/Tools/EntityManager/NameUtil.php | 1 + .../Tools/Stream/Api/GetStreamAttachments.php | 85 ++++++++++++ .../Espo/Tools/Stream/RecordService.php | 129 ++++++++++++++---- .../attachment/fields/name/detail.tpl | 2 +- .../attachment/fields/name-for-stream.js | 59 ++++++++ client/src/views/modals/related-list.js | 6 + client/src/views/modals/select-records.js | 5 + client/src/views/stream/panel.js | 49 +++++++ 11 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 application/Espo/Resources/layouts/Attachment/listForStream.json create mode 100644 application/Espo/Tools/Stream/Api/GetStreamAttachments.php create mode 100644 client/src/views/attachment/fields/name-for-stream.js diff --git a/application/Espo/Resources/i18n/en_US/Note.json b/application/Espo/Resources/i18n/en_US/Note.json index fb93c385a7..84c3a2dc25 100644 --- a/application/Espo/Resources/i18n/en_US/Note.json +++ b/application/Espo/Resources/i18n/en_US/Note.json @@ -44,6 +44,7 @@ }, "labels": { "View Posts": "View Posts", + "View Attachments": "View Attachments", "View Activity": "View Activity", "Pin": "Pin", "Unpin": "Unpin", diff --git a/application/Espo/Resources/layouts/Attachment/listForStream.json b/application/Espo/Resources/layouts/Attachment/listForStream.json new file mode 100644 index 0000000000..45e501d757 --- /dev/null +++ b/application/Espo/Resources/layouts/Attachment/listForStream.json @@ -0,0 +1,8 @@ +[ + { + "name": "name", + "view": "views/attachment/fields/name-for-stream" + }, + {"name": "createdBy", "width": 18}, + {"name": "createdAt", "width": 18} +] diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 4fdee26e9d..b3908fe489 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -610,6 +610,11 @@ "action": "unfollow" } }, + { + "route": "/:entityType/:id/streamAttachments", + "method": "get", + "actionClassName": "Espo\\Tools\\Stream\\Api\\GetStreamAttachments" + }, { "route": "/:Note/:id/pin", "method": "post", diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php index 667f9a2c77..121bf4ad70 100644 --- a/application/Espo/Tools/EntityManager/NameUtil.php +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -167,6 +167,7 @@ class NameUtil public const LINK_FORBIDDEN_NAME_LIST = [ 'posts', 'stream', + 'streamAttachments', 'subscription', 'starSubscription', 'action', diff --git a/application/Espo/Tools/Stream/Api/GetStreamAttachments.php b/application/Espo/Tools/Stream/Api/GetStreamAttachments.php new file mode 100644 index 0000000000..df0e443b59 --- /dev/null +++ b/application/Espo/Tools/Stream/Api/GetStreamAttachments.php @@ -0,0 +1,85 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Tools\Stream\Api; + +use Espo\Core\Acl; +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Record\EntityProvider; +use Espo\Core\Record\SearchParamsFetcher; +use Espo\ORM\Entity; +use Espo\Tools\Stream\RecordService; + +/** + * @noinspection PhpUnused + */ +class GetStreamAttachments implements Action +{ + public function __construct( + private EntityProvider $entityProvider, + private Acl $acl, + private SearchParamsFetcher $searchParamsFetcher, + private RecordService $service, + ) {} + + public function process(Request $request): Response + { + $entity = $this->getEntity($request); + $searchParams = $this->searchParamsFetcher->fetch($request); + + $result = $this->service->findAttachments($entity, $searchParams); + + return ResponseComposer::json($result->toApiOutput()); + } + + /** + * @throws Forbidden + * @throws NotFound + * @throws BadRequest + */ + private function getEntity(Request $request): Entity + { + $entityType = $request->getRouteParam('entityType') ?? throw new BadRequest(); + $id = $request->getRouteParam('id') ?? throw new BadRequest(); + + $entity = $this->entityProvider->get($entityType, $id); + + if (!$this->acl->checkEntityStream($entity)) { + throw new Forbidden("No 'stream' access."); + } + + return $entity; + } +} diff --git a/application/Espo/Tools/Stream/RecordService.php b/application/Espo/Tools/Stream/RecordService.php index 5a1294819e..9f9ada553e 100644 --- a/application/Espo/Tools/Stream/RecordService.php +++ b/application/Espo/Tools/Stream/RecordService.php @@ -34,8 +34,11 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Name\Field; use Espo\Core\Select\SearchParams; +use Espo\Core\Select\SelectBuilderFactory; use Espo\Core\Utils\Metadata; +use Espo\Entities\Attachment; use Espo\ORM\Collection; +use Espo\ORM\Entity; use Espo\ORM\EntityManager; use Espo\Entities\User; use Espo\Entities\Note; @@ -43,6 +46,8 @@ use Espo\Entities\Email; use Espo\Core\Acl; use Espo\Core\Acl\Table; use Espo\Core\Record\Collection as RecordCollection; +use Espo\ORM\Query\Part\Condition; +use Espo\ORM\Query\Part\Expression; use Espo\ORM\Query\SelectBuilder; use Espo\Tools\Stream\RecordService\Helper; use Espo\Tools\Stream\RecordService\NoteHelper; @@ -62,6 +67,7 @@ class RecordService private Metadata $metadata, private NoteHelper $noteHelper, private MassNotePreparator $massNotePreparator, + private SelectBuilderFactory $selectBuilderFactory, ) {} /** @@ -174,33 +180,7 @@ class RecordService */ private function findInternal(string $scope, string $id, SearchParams $searchParams): RecordCollection { - $builder = $this->queryHelper->buildBaseQueryBuilder($searchParams); - - $where = $this->user->isPortal() ? - [ - 'parentType' => $scope, - 'parentId' => $id, - 'isInternal' => false, - ] : - [ - 'OR' => [ - [ - 'parentType' => $scope, - 'parentId' => $id, - ], - [ - 'superParentType' => $scope, - 'superParentId' => $id, - ], - ] - ]; - - $this->applyPortalAccess($builder, $where); - $this->applyAccess($builder, $id, $scope, $where); - $this->applyIgnore($where); - $this->applyStatusIgnore($scope, $where); - - $builder->where($where); + $builder = $this->prepareSelectBuilder($scope, $id, $searchParams); $offset = $searchParams->getOffset(); $maxSize = $searchParams->getMaxSize(); @@ -468,4 +448,99 @@ class RecordService throw new Forbidden("No stream access."); } } + + /** + * @return RecordCollection + * @throws BadRequest + * @throws Forbidden + * @throws NotFound + * @since 9.1.0 + * @internal + */ + public function findAttachments(Entity $entity, SearchParams $searchParams): RecordCollection + { + $entityType = $entity->getEntityType(); + $id = $entity->getId(); + + $this->checkAccess($entityType, $id); + + $noteBuilder = $this->prepareSelectBuilder($entityType, $id, SearchParams::create()); + + $noteBuilder->select(['id']); + + $searchParams = $searchParams->withSelect([ + 'id', + 'name', + 'type', + 'size', + 'parentType', + 'parentId', + 'createdAt', + 'createdById', + 'createdByName', + ]); + + $query = $this->selectBuilderFactory + ->create() + ->from(Attachment::ENTITY_TYPE) + ->withSearchParams($searchParams) + ->buildQueryBuilder() + ->where( + Condition::in( + Expression::column('parentId'), + $noteBuilder->build() + ) + ) + ->where(['parentType' => Note::ENTITY_TYPE]) + ->build(); + + $collection = $this->entityManager + ->getRDBRepositoryByClass(Attachment::class) + ->clone($query) + ->find(); + + $total = $this->entityManager + ->getRDBRepositoryByClass(Attachment::class) + ->clone($query) + ->count(); + + return RecordCollection::create($collection, $total); + } + + /** + * @throws BadRequest + * @throws Forbidden + */ + private function prepareSelectBuilder(string $scope, string $id, SearchParams $searchParams): SelectBuilder + { + $builder = $this->queryHelper->buildBaseQueryBuilder($searchParams); + + $where = $this->user->isPortal() ? + [ + 'parentType' => $scope, + 'parentId' => $id, + 'isInternal' => false, + ] : + [ + 'OR' => [ + [ + 'parentType' => $scope, + 'parentId' => $id, + ], + [ + 'superParentType' => $scope, + 'superParentId' => $id, + ], + ] + ]; + + $this->applyPortalAccess($builder, $where); + $this->applyAccess($builder, $id, $scope, $where); + $this->applyIgnore($where); + $this->applyStatusIgnore($scope, $where); + + $builder->where($where); + + return $builder; + } } diff --git a/client/res/templates/attachment/fields/name/detail.tpl b/client/res/templates/attachment/fields/name/detail.tpl index ee4a129e43..8afa564733 100644 --- a/client/res/templates/attachment/fields/name/detail.tpl +++ b/client/res/templates/attachment/fields/name/detail.tpl @@ -1,2 +1,2 @@ - {{value}} + {{value}} diff --git a/client/src/views/attachment/fields/name-for-stream.js b/client/src/views/attachment/fields/name-for-stream.js new file mode 100644 index 0000000000..44109dd54a --- /dev/null +++ b/client/src/views/attachment/fields/name-for-stream.js @@ -0,0 +1,59 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +import Model from 'model'; +import FileFieldView from 'views/fields/file'; +import BaseFieldView from 'views/fields/base'; + +export default class extends BaseFieldView { + + // language=Handlebars + listTemplateContent = ` + {{{subField}}} + ` + + prepare() { + const model = new Model({ + fileId: this.model.id, + fileName: this.model.attributes.name, + fileType: this.model.attributes.type, + }); + + const view = new FileFieldView({ + name: 'file', + model: model, + params: { + showPreview: true, + listPreviewSize: 'small', + }, + mode: 'list', + }); + + return this.assignView('subField', view); + } +} diff --git a/client/src/views/modals/related-list.js b/client/src/views/modals/related-list.js index 78d2a943e6..01807ce19a 100644 --- a/client/src/views/modals/related-list.js +++ b/client/src/views/modals/related-list.js @@ -36,6 +36,8 @@ import CreateRelatedHelper from 'helpers/record/create-related'; /** * A related-list modal. + * + * @todo JSDocs constructor options. */ class RelatedListModalView extends ModalView { @@ -135,6 +137,10 @@ class RelatedListModalView extends ModalView { this.panelCollection = this.options.panelCollection; + if (this.options.searchPanelDisabled) { + this.searchPanel = false; + } + if (this.panelCollection) { this.listenTo(this.panelCollection, 'sync', (c, r, o) => { if (o.skipCollectionSync) { diff --git a/client/src/views/modals/select-records.js b/client/src/views/modals/select-records.js index c3561ce3d4..abb0df6a05 100644 --- a/client/src/views/modals/select-records.js +++ b/client/src/views/modals/select-records.js @@ -104,6 +104,7 @@ class SelectRecordsModalView extends ModalView { * @property {function({where: Record[], searchParams: module:collection~Data})} [onMassSelect] * On record select. As of 9.1.0. * @property {function()} [onCreate] On create click. As of 9.0.5. + * @property {boolean} [searchPanelDisabled] Disable the search panel. */ /** @@ -222,6 +223,10 @@ class SelectRecordsModalView extends ModalView { } } + if (this.options.searchPanelDisabled) { + this.searchPanel = false; + } + if (!this.options.headerText) { this.$header = $(''); diff --git a/client/src/views/stream/panel.js b/client/src/views/stream/panel.js index 74173dcf20..d394c8617b 100644 --- a/client/src/views/stream/panel.js +++ b/client/src/views/stream/panel.js @@ -781,6 +781,14 @@ class PanelStreamView extends RelationshipPanelView { onClick: () => this.actionViewPostList(), }); + if (this.model.entityType !== 'User') { + this.actionList.push({ + action: 'viewAttachmentList', + text: this.translate('View Attachments', 'labels', 'Note'), + onClick: () => this.actionViewAttachmentList(), + }); + } + if (this.model.entityType === 'User') { this.actionList.push({ action: 'viewUserActivity', @@ -834,6 +842,47 @@ class PanelStreamView extends RelationshipPanelView { this.actionViewRelatedList(data); } + /** + * @private + */ + actionViewAttachmentList() { + const url = `${this.model.entityType}/${this.model.id}/streamAttachments`; + + const title = `${this.translate('Stream')} @right ${this.translate('Attachment', 'scopeNamesPlural')}`; + + const options = { + model: this.model, + link: 'streamAttachments', + entityType: 'Attachment', + title: title, + layoutName: 'listForStream', + defaultOrder: 'desc', + defaultOrderBy: 'createdAt', + url: url, + listViewName: this.listViewName, + createDisabled: true, + selectDisabled: true, + unlinkDisabled: true, + removeDisabled: true, + rowActionsView: null, + filtersDisabled: true, + searchPanelDisabled: true, + massActionRemoveDisabled: true, + }; + + Espo.Ui.notifyWait(); + + this.createView('modal', 'views/modals/related-list', options, view => { + Espo.Ui.notify(); + + view.render(); + + this.listenTo(view, 'action', (event, element) => { + Espo.Utils.handleAction(this, event, element); + }); + }); + } + actionViewUserActivity() { const url = `User/${this.model.id}/stream/own`;