diff --git a/appinfo/info.xml b/appinfo/info.xml index d8bf86b6..785fdeb9 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -13,7 +13,7 @@ Approve/reject files based on workflows defined by admins. **Warning**: The DocuSign integration is no longer part of this app and can be installed with [this app](https://apps.nextcloud.com/apps/integration_docusign). ]]> - 2.4.0 + 2.5.0 agpl Julien Veyssier Approval diff --git a/lib/Controller/ApprovalController.php b/lib/Controller/ApprovalController.php index c6b6236d..a696d91d 100644 --- a/lib/Controller/ApprovalController.php +++ b/lib/Controller/ApprovalController.php @@ -55,6 +55,7 @@ public function getApprovalState(int $fileId): DataResponse { $state['userId'] = $activity['userId']; $state['userName'] = $activity['userName']; $state['timestamp'] = $activity['timestamp']; + $state['message'] = $activity['message']; } } return new DataResponse($state); @@ -74,11 +75,12 @@ public function getPendingNodes(?int $since = null): DataResponse { * Approve a file * * @param int $fileId + * @param string|null $message * @return DataResponse */ #[NoAdminRequired] - public function approve(int $fileId): DataResponse { - $this->approvalService->approve($fileId, $this->userId); + public function approve(int $fileId, ?string $message = ''): DataResponse { + $this->approvalService->approve($fileId, $this->userId, $message); return new DataResponse(1); } @@ -86,11 +88,12 @@ public function approve(int $fileId): DataResponse { * Reject a file * * @param int $fileId + * @param string|null $message * @return DataResponse */ #[NoAdminRequired] - public function reject(int $fileId): DataResponse { - $this->approvalService->reject($fileId, $this->userId); + public function reject(int $fileId, ?string $message = ''): DataResponse { + $this->approvalService->reject($fileId, $this->userId, $message); return new DataResponse(1); } diff --git a/lib/Migration/Version020400Date20250919105115.php b/lib/Migration/Version020400Date20250919105115.php new file mode 100644 index 00000000..1bc888a7 --- /dev/null +++ b/lib/Migration/Version020400Date20250919105115.php @@ -0,0 +1,55 @@ +hasTable('approval_activity')) { + $table = $schema->getTable('approval_activity'); + if (!$table->hasColumn('message')) { + $table->addColumn('message', Types::TEXT, [ + 'notnull' => false, // OCI considers an empty string to be the same as a null value + ]); + } + } + + return $schema; + } +} diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php index bda50992..09eb2f83 100644 --- a/lib/Service/ApprovalService.php +++ b/lib/Service/ApprovalService.php @@ -339,9 +339,10 @@ public function getApprovalState(int $fileId, ?string $userId, bool $userHasAcce * * @param int $fileId * @param string|null $userId + * @param string $message * @return bool success */ - public function approve(int $fileId, ?string $userId): bool { + public function approve(int $fileId, ?string $userId, string $message = ''): bool { $fileState = $this->getApprovalState($fileId, $userId); // if file has pending tag and user is authorized to approve it if ($fileState['state'] === Application::STATE_APPROVABLE) { @@ -354,7 +355,7 @@ public function approve(int $fileId, ?string $userId): bool { $this->tagObjectMapper->unassignTags((string)$fileId, 'files', $rule['tagPending']); // store activity in our tables - $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_APPROVED); + $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_APPROVED, $message); $this->sendApprovalNotification($fileId, $userId, true); $this->activityManager->triggerEvent( @@ -376,9 +377,10 @@ public function approve(int $fileId, ?string $userId): bool { * * @param int $fileId * @param string|null $userId + * @param string $message * @return bool success */ - public function reject(int $fileId, ?string $userId): bool { + public function reject(int $fileId, ?string $userId, string $message = ''): bool { $fileState = $this->getApprovalState($fileId, $userId); // if file has pending tag and user is authorized to approve it if ($fileState['state'] === Application::STATE_APPROVABLE) { @@ -391,7 +393,7 @@ public function reject(int $fileId, ?string $userId): bool { $this->tagObjectMapper->unassignTags((string)$fileId, 'files', $rule['tagPending']); // store activity in our tables - $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_REJECTED); + $this->ruleService->storeAction($fileId, $ruleId, $userId, Application::STATE_REJECTED, $message); $this->sendApprovalNotification($fileId, $userId, false); $this->activityManager->triggerEvent( diff --git a/lib/Service/RuleService.php b/lib/Service/RuleService.php index f7e391d5..b78394ae 100644 --- a/lib/Service/RuleService.php +++ b/lib/Service/RuleService.php @@ -441,9 +441,10 @@ private function getRuleEntities(int $ruleId, string $role): array { * @param int $ruleId * @param string $userId * @param int $newState + * @param string $message * @return void */ - public function storeAction(int $fileId, int $ruleId, string $userId, int $newState): void { + public function storeAction(int $fileId, int $ruleId, string $userId, int $newState, string $message = ''): void { $qb = $this->db->getQueryBuilder(); $qb->delete('approval_activity') ->where( @@ -463,6 +464,7 @@ public function storeAction(int $fileId, int $ruleId, string $userId, int $newSt 'user_id' => $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), 'new_state' => $qb->createNamedParameter($newState, IQueryBuilder::PARAM_INT), 'timestamp' => $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT), + 'message' => $qb->createNamedParameter($message, IQueryBuilder::PARAM_STR), ]); $qb->executeStatement(); $qb->resetQueryParts(); @@ -497,6 +499,7 @@ public function getLastAction(int $fileId, int $ruleId, int $newState): ?array { $activity = [ 'userId' => $row['user_id'], 'timestamp' => (int)$row['timestamp'], + 'message' => $row['message'] ?? '', ]; break; } diff --git a/src/components/Info.vue b/src/components/Info.vue index 4640d6b8..0d5a5d29 100644 --- a/src/components/Info.vue +++ b/src/components/Info.vue @@ -18,6 +18,7 @@ ) }} + {{ rejectedText }} + + {{ messageText }} + @@ -67,9 +71,11 @@ import CheckIcon from 'vue-material-design-icons/Check.vue' import CheckCircleIcon from 'vue-material-design-icons/CheckCircle.vue' import DotsHorizontalCircleIcon from 'vue-material-design-icons/DotsHorizontalCircle.vue' import CloseCircleIcon from 'vue-material-design-icons/CloseCircle.vue' +import MessageDrawIcon from 'vue-material-design-icons/MessageDraw.vue' import NcButton from '@nextcloud/vue/components/NcButton' import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' +import NcInputField from '@nextcloud/vue/components/NcInputField' import ApprovalButtons from './ApprovalButtons.vue' @@ -85,10 +91,12 @@ export default { ApprovalButtons, NcButton, NcUserBubble, + NcInputField, CheckCircleIcon, CloseCircleIcon, CheckIcon, DotsHorizontalCircleIcon, + MessageDrawIcon, }, props: { @@ -100,6 +108,10 @@ export default { type: [Number, null], default: null, }, + message: { + type: String, + default: '', + }, userName: { type: [String, null], default: null, @@ -143,6 +155,7 @@ export default { data() { return { you: t('approval', 'you'), + newMessage: '', } }, @@ -218,6 +231,15 @@ export default { ? t('approval', 'Approval requested by {user}', { user: this.userName }) : t('approval', 'Approval requested by you') }, + messageText() { + if (this.stateApproved) { + return t('approval', 'Reason for approval: {message}', { message: this.message }) + } + if (this.stateRejected) { + return t('approval', 'Reason for rejection: {message}', { message: this.message }) + } + return this.message + }, }, watch: {}, @@ -226,10 +248,10 @@ export default { methods: { onApprove() { - this.$emit('approve') + this.$emit('approve', this.newMessage) }, onReject() { - this.$emit('reject') + this.$emit('reject', this.newMessage) }, onRequest() { this.$emit('request') diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue index 72394ddd..df95ecca 100644 --- a/src/components/InfoModal.vue +++ b/src/components/InfoModal.vue @@ -82,13 +82,13 @@ export default { setUserRules(rules) { this.userRules = rules }, - onApprove() { + onApprove(message) { this.closeModal() - this.$emit('approve', this.node) + this.$emit('approve', this.node, message) }, - onReject() { + onReject(message) { this.closeModal() - this.$emit('reject', this.node) + this.$emit('reject', this.node, message) }, onRequest() { this.closeModal() diff --git a/src/files/actions/approveAction.js b/src/files/actions/approveAction.js index fc8b79ea..d0c5f712 100644 --- a/src/files/actions/approveAction.js +++ b/src/files/actions/approveAction.js @@ -17,6 +17,7 @@ export const approveAction = new FileAction({ return !OCA.Approval.actionIgnoreLists.includes(view.id) && !nodes.some(({ permissions }) => (permissions & Permission.READ) === 0) && nodes.some(node => node.attributes['approval-state'] === states.APPROVABLE) + && nodes.length > 1 // && nodes.every(({ type }) => type === FileType.File) // && nodes.every(({ mime }) => mime === 'application/some+type') }, diff --git a/src/files/actions/inlineAction.js b/src/files/actions/inlineAction.js index 06648d34..13c82d63 100644 --- a/src/files/actions/inlineAction.js +++ b/src/files/actions/inlineAction.js @@ -21,7 +21,7 @@ export const inlineAction = new FileAction({ return state === states.PENDING ? t('approval', 'Waiting for authorized users to approve this file') : state === states.APPROVABLE - ? t('approval', 'Pending approval, you are authorized to approve') + ? t('approval', 'Pending approval, click to approve/reject') : state === states.APPROVED ? t('approval', 'This element was approved') : t('approval', 'This element was rejected') diff --git a/src/files/actions/rejectAction.js b/src/files/actions/rejectAction.js index 6862e995..2cd77faf 100644 --- a/src/files/actions/rejectAction.js +++ b/src/files/actions/rejectAction.js @@ -17,6 +17,7 @@ export const rejectAction = new FileAction({ return !OCA.Approval.actionIgnoreLists.includes(view.id) && !nodes.some(({ permissions }) => (permissions & Permission.READ) === 0) && nodes.some(node => node.attributes['approval-state'] === states.APPROVABLE) + && nodes.length > 1 // && nodes.every(({ type }) => type === FileType.File) // && nodes.every(({ mime }) => mime === 'application/some+type') }, diff --git a/src/files/actions/respondAction.js b/src/files/actions/respondAction.js new file mode 100644 index 00000000..c02176ba --- /dev/null +++ b/src/files/actions/respondAction.js @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import PendingIconSvg from '@mdi/svg/svg/dots-horizontal-circle-outline.svg?raw' +import { Permission, FileAction } from '@nextcloud/files' +import { states } from '../../states.js' +import { openApprovalInfoModal, updateNodeApprovalState } from '../helpers.js' + +export const respondAction = new FileAction({ + id: 'approval-respond', + displayName: (nodes) => { + return t('approval', 'Approve or Reject') + }, + enabled(nodes, view) { + return !OCA.Approval.actionIgnoreLists.includes(view.id) + && !nodes.some(({ permissions }) => (permissions & Permission.READ) === 0) + && nodes.some(node => node.attributes['approval-state'] === states.APPROVABLE) + && nodes.length === 1 + // && nodes.every(({ type }) => type === FileType.File) + // && nodes.every(({ mime }) => mime === 'application/some+type') + }, + iconSvgInline: () => PendingIconSvg, + order: 0, + async exec(node) { + try { + await updateNodeApprovalState(node) + await openApprovalInfoModal(node) + } catch (error) { + console.debug('Approve or Reject action failed') + } + return null + }, + async execBatch(nodes) { + }, +}) diff --git a/src/files/helpers.js b/src/files/helpers.js index 16ac5015..e15464df 100644 --- a/src/files/helpers.js +++ b/src/files/helpers.js @@ -83,10 +83,10 @@ export async function requestAfterShareCreation(fileId, fileName, ruleId, node = } } -export async function approve(fileId, fileName, node = null, notify = true) { +export async function approve(fileId, fileName, node = null, notify = true, message = '') { const url = generateOcsUrl('apps/approval/api/v1/approve/{fileId}', { fileId }) try { - await axios.put(url) + await axios.put(url, { message }) if (notify) { showSuccess(t('approval', 'You approved {name}', { name: fileName })) } @@ -102,10 +102,10 @@ export async function approve(fileId, fileName, node = null, notify = true) { } } -export async function reject(fileId, fileName, node = null, notify = true) { +export async function reject(fileId, fileName, node = null, notify = true, message = '') { const url = generateOcsUrl('apps/approval/api/v1/reject/{fileId}', { fileId }) try { - await axios.put(url) + await axios.put(url, { message }) if (notify) { showSuccess(t('approval', 'You rejected {name}', { name: fileName })) } diff --git a/src/files/init.js b/src/files/init.js index 1e18a5b9..9c67773d 100644 --- a/src/files/init.js +++ b/src/files/init.js @@ -7,6 +7,7 @@ import { registerFileAction, registerDavProperty } from '@nextcloud/files' import { inlineAction } from './actions/inlineAction.js' import { requestAction } from './actions/requestAction.js' +import { respondAction } from './actions/respondAction.js' import { approveAction } from './actions/approveAction.js' import { rejectAction } from './actions/rejectAction.js' @@ -24,3 +25,4 @@ registerFileAction(inlineAction) registerFileAction(approveAction) registerFileAction(rejectAction) registerFileAction(requestAction) +registerFileAction(respondAction) diff --git a/src/files/modals.js b/src/files/modals.js index e0734b8d..fabc521d 100644 --- a/src/files/modals.js +++ b/src/files/modals.js @@ -38,11 +38,11 @@ export function createInfoModal() { onClose: () => { console.debug('[Approval] modal closed') }, - onApprove: (node) => { - approve(node.fileid, node.basename, node) + onApprove: (node, message) => { + approve(node.fileid, node.basename, node, true, message) }, - onReject: (node) => { - reject(node.fileid, node.basename, node) + onReject: (node, message) => { + reject(node.fileid, node.basename, node, true, message) }, onRequest: (node) => { onRequestFileAction(node) diff --git a/src/views/ApprovalTab.vue b/src/views/ApprovalTab.vue index d8e77758..ed59d8bd 100644 --- a/src/views/ApprovalTab.vue +++ b/src/views/ApprovalTab.vue @@ -8,6 +8,7 @@ { this.state = response.data.ocs.data.state this.timestamp = response.data.ocs.data.timestamp + this.message = response.data.ocs.data.message this.userId = response.data.ocs.data.userId this.userName = response.data.ocs.data.userName this.rule = response.data.ocs.data.rule @@ -88,18 +91,18 @@ export default { this.userRules = response.data.ocs.data }) }, - async onApprove() { + async onApprove(message) { this.state = null const fileId = this.fileInfo.id const fileName = this.fileInfo.name - await approve(fileId, fileName) + await approve(fileId, fileName, null, true, message) this.update(this.fileInfo) }, - async onReject() { + async onReject(message) { this.state = null const fileId = this.fileInfo.id const fileName = this.fileInfo.name - await reject(fileId, fileName) + await reject(fileId, fileName, null, true, message) this.update(this.fileInfo) }, async onRequestSubmit(ruleId, createShares) {