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) {