Skip to content

Commit 2ff4bb1

Browse files
Document: Fix document move & folder listing (resource_link hierarchy) + adjust migration ordering - refs #6372
1 parent fca7218 commit 2ff4bb1

File tree

4 files changed

+269
-56
lines changed

4 files changed

+269
-56
lines changed

assets/vue/views/documents/DocumentsList.vue

Lines changed: 136 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -977,105 +977,139 @@ function recordedAudioNotSaved(error) {
977977
console.error(error)
978978
}
979979
980-
function openMoveDialog(document) {
981-
item.value = document
982-
isMoveDialogVisible.value = true
983-
}
984-
985-
function openReplaceDialog(document) {
986-
if (!canEdit(document)) {
987-
return
980+
/**
981+
* -----------------------------------------
982+
* MOVE: helpers + folders fetching
983+
* -----------------------------------------
984+
*/
985+
function normalizeResourceNodeId(value) {
986+
if (value == null) return null
987+
if (typeof value === "number") return value
988+
989+
if (typeof value === "string") {
990+
// Accept IRI like "/api/resource_nodes/123"
991+
const iriMatch = value.match(/\/api\/resource_nodes\/(\d+)/)
992+
if (iriMatch) return Number(iriMatch[1])
993+
994+
// Accept "123"
995+
if (/^\d+$/.test(value)) return Number(value)
988996
}
989997
990-
documentToReplace.value = document
991-
isReplaceDialogVisible.value = true
998+
return null
992999
}
9931000
994-
async function replaceDocument() {
995-
if (!selectedReplaceFile.value) {
996-
notification.showErrorNotification(t("No file selected."))
997-
return
998-
}
1001+
function getRootNodeIdForFolders() {
1002+
let node = resourceNode.value
1003+
let fallback =
1004+
normalizeResourceNodeId(node?.id) ??
1005+
normalizeResourceNodeId(route.params.node) ??
1006+
normalizeResourceNodeId(route.query.node)
9991007
1000-
if (!(documentToReplace.value.filetype === "file" || documentToReplace.value.filetype === "video")) {
1001-
notification.showErrorNotification(t("Only files can be replaced."))
1002-
return
1008+
while (node?.parent) {
1009+
if (node?.resourceType?.title === "courses") break
1010+
node = node.parent
10031011
}
10041012
1005-
const formData = new FormData()
1006-
formData.append("file", selectedReplaceFile.value)
1007-
try {
1008-
await axios.post(`/api/documents/${documentToReplace.value.iid}/replace`, formData, {
1009-
headers: {
1010-
"Content-Type": "multipart/form-data",
1011-
},
1012-
})
1013-
notification.showSuccessNotification(t("File replaced"))
1014-
isReplaceDialogVisible.value = false
1015-
onUpdateOptions(options.value)
1016-
} catch (error) {
1017-
notification.showErrorNotification(t("Error replacing file."))
1018-
console.error(error)
1019-
}
1013+
return normalizeResourceNodeId(node?.id) ?? fallback
10201014
}
10211015
10221016
async function fetchFolders(nodeId = null, parentPath = "") {
1017+
const startId = normalizeResourceNodeId(nodeId || route.params.node || route.query.node)
1018+
10231019
const foldersList = [
10241020
{
10251021
label: t("Documents"),
1026-
value: nodeId || route.params.node || route.query.node || "root-node-id",
1022+
value: startId || "root-node-id",
10271023
},
10281024
]
10291025
10301026
try {
1031-
let nodesToFetch = [{ id: nodeId || route.params.node || route.query.node, path: parentPath }]
1027+
let nodesToFetch = [{ id: startId, path: parentPath }]
10321028
let depth = 0
10331029
const maxDepth = 5
10341030
10351031
while (nodesToFetch.length > 0 && depth < maxDepth) {
10361032
const currentNode = nodesToFetch.shift()
1033+
const currentNodeId = normalizeResourceNodeId(currentNode?.id)
1034+
1035+
if (!currentNodeId) {
1036+
depth++
1037+
continue
1038+
}
10371039
10381040
const response = await axios.get("/api/documents", {
10391041
params: {
1040-
filetype: "folder",
1041-
"resourceNode.parent": currentNode.id,
1042-
cid: route.query.cid,
1043-
sid: route.query.sid,
1042+
loadNode: 1,
1043+
filetype: ["folder"],
1044+
"resourceNode.parent": currentNodeId,
1045+
cid,
1046+
sid,
1047+
gid,
1048+
page: 1,
1049+
itemsPerPage: 200,
10441050
},
10451051
})
10461052
1047-
response.data["hydra:member"].forEach((folder) => {
1048-
const fullPath = `${currentNode.path}/${folder.title}`
1053+
const members = response.data?.["hydra:member"] || []
1054+
1055+
members.forEach((folder) => {
1056+
const folderNodeId =
1057+
normalizeResourceNodeId(folder?.resourceNode?.id) ?? normalizeResourceNodeId(folder?.resourceNodeId)
1058+
1059+
if (!folderNodeId) {
1060+
return
1061+
}
1062+
1063+
const fullPath = `${currentNode.path}/${folder.title}`.replace(/^\/+/, "")
10491064
10501065
foldersList.push({
10511066
label: fullPath,
1052-
value: folder.resourceNode?.id || folder.resourceNodeId || folder["@id"],
1067+
value: folderNodeId, // ALWAYS numeric
10531068
})
10541069
1055-
if (folder.resourceNode && folder.resourceNode.id) {
1056-
nodesToFetch.push({ id: folder.resourceNode.id, path: fullPath })
1057-
}
1070+
nodesToFetch.push({ id: folderNodeId, path: fullPath })
10581071
})
10591072
10601073
depth++
10611074
}
10621075
10631076
return foldersList
10641077
} catch (error) {
1065-
console.error("Error fetching folders:", error.message || error)
1066-
return []
1078+
console.error("Error fetching folders:", error?.message || error)
1079+
return foldersList
10671080
}
10681081
}
10691082
10701083
async function loadAllFolders() {
1084+
// Keep your behavior: start from current node.
1085+
// If you want ALWAYS from course root, tell me and I’ll adjust in 2 lines.
10711086
folders.value = await fetchFolders()
10721087
}
10731088
1089+
async function openMoveDialog(document) {
1090+
item.value = document
1091+
selectedFolder.value = null
1092+
await loadAllFolders()
1093+
isMoveDialogVisible.value = true
1094+
}
1095+
10741096
async function moveDocument() {
10751097
try {
1076-
const response = await axios.put(`/api/documents/${item.value.iid}/move`, {
1077-
parentResourceNodeId: selectedFolder.value,
1078-
})
1098+
const parentId = normalizeResourceNodeId(selectedFolder.value)
1099+
1100+
if (!parentId) {
1101+
notification.showErrorNotification(t("Select a folder"))
1102+
return
1103+
}
1104+
1105+
await axios.put(
1106+
`/api/documents/${item.value.iid}/move`,
1107+
{ parentResourceNodeId: parentId },
1108+
{
1109+
// IMPORTANT: backend needs context to move the correct resource_link
1110+
params: { cid, sid, gid },
1111+
},
1112+
)
10791113
10801114
notification.showSuccessNotification(t("Document moved successfully"))
10811115
isMoveDialogVisible.value = false
@@ -1086,6 +1120,53 @@ async function moveDocument() {
10861120
}
10871121
}
10881122
1123+
/**
1124+
* -----------------------------------------
1125+
* REPLACE
1126+
* -----------------------------------------
1127+
*/
1128+
function openReplaceDialog(document) {
1129+
if (!canEdit(document)) {
1130+
return
1131+
}
1132+
1133+
documentToReplace.value = document
1134+
isReplaceDialogVisible.value = true
1135+
}
1136+
1137+
async function replaceDocument() {
1138+
if (!selectedReplaceFile.value) {
1139+
notification.showErrorNotification(t("No file selected."))
1140+
return
1141+
}
1142+
1143+
if (!(documentToReplace.value.filetype === "file" || documentToReplace.value.filetype === "video")) {
1144+
notification.showErrorNotification(t("Only files can be replaced."))
1145+
return
1146+
}
1147+
1148+
const formData = new FormData()
1149+
formData.append("file", selectedReplaceFile.value)
1150+
try {
1151+
await axios.post(`/api/documents/${documentToReplace.value.iid}/replace`, formData, {
1152+
headers: {
1153+
"Content-Type": "multipart/form-data",
1154+
},
1155+
})
1156+
notification.showSuccessNotification(t("File replaced"))
1157+
isReplaceDialogVisible.value = false
1158+
onUpdateOptions(options.value)
1159+
} catch (error) {
1160+
notification.showErrorNotification(t("Error replacing file."))
1161+
console.error(error)
1162+
}
1163+
}
1164+
1165+
/**
1166+
* -----------------------------------------
1167+
* CERTIFICATES
1168+
* -----------------------------------------
1169+
*/
10891170
async function selectAsDefaultCertificate(certificate) {
10901171
try {
10911172
const response = await axios.patch(`/gradebook/set_default_certificate/${cid}/${certificate.iid}`)
@@ -1113,6 +1194,11 @@ async function loadDefaultCertificate() {
11131194
}
11141195
}
11151196
1197+
/**
1198+
* -----------------------------------------
1199+
* TEMPLATE
1200+
* -----------------------------------------
1201+
*/
11161202
const showTemplateFormModal = ref(false)
11171203
const selectedFile = ref(null)
11181204
const templateFormData = ref({
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\Controller\Api;
8+
9+
use Chamilo\CoreBundle\Entity\Course;
10+
use Chamilo\CoreBundle\Entity\ResourceLink;
11+
use Chamilo\CoreBundle\Entity\ResourceNode;
12+
use Chamilo\CoreBundle\Entity\Session;
13+
use Chamilo\CoreBundle\Repository\ResourceLinkRepository;
14+
use Chamilo\CourseBundle\Entity\CGroup;
15+
use Chamilo\CourseBundle\Entity\CDocument;
16+
use Doctrine\ORM\EntityManagerInterface;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
19+
20+
final class MoveDocumentAction
21+
{
22+
public function __construct(
23+
private EntityManagerInterface $em,
24+
private ResourceLinkRepository $linkRepo,
25+
) {}
26+
27+
public function __invoke(CDocument $document, Request $request): CDocument
28+
{
29+
$payload = json_decode((string) $request->getContent(), true);
30+
if (!is_array($payload)) {
31+
throw new BadRequestHttpException('Invalid JSON body.');
32+
}
33+
34+
$rawParent = $payload['parentResourceNodeId'] ?? null;
35+
if (null === $rawParent || '' === $rawParent) {
36+
throw new BadRequestHttpException('Missing "parentResourceNodeId".');
37+
}
38+
39+
$destNodeId = $this->normalizeNodeId($rawParent);
40+
if (null === $destNodeId) {
41+
throw new BadRequestHttpException('Invalid "parentResourceNodeId".');
42+
}
43+
44+
$cid = $request->query->getInt('cid', 0);
45+
$sid = $request->query->getInt('sid', 0);
46+
$gid = $request->query->getInt('gid', 0);
47+
48+
$hasContext = $cid > 0 || $sid > 0 || $gid > 0;
49+
50+
/** @var ResourceNode|null $destNode */
51+
$destNode = $this->em->getRepository(ResourceNode::class)->find($destNodeId);
52+
if (!$destNode) {
53+
throw new BadRequestHttpException('Destination folder node not found.');
54+
}
55+
56+
$docNode = $document->getResourceNode();
57+
if (!$docNode) {
58+
throw new BadRequestHttpException('Document resource node not found.');
59+
}
60+
61+
if ($hasContext) {
62+
$course = $cid > 0 ? $this->em->getRepository(Course::class)->find($cid) : null;
63+
$session = $sid > 0 ? $this->em->getRepository(Session::class)->find($sid) : null;
64+
$group = $gid > 0 ? $this->em->getRepository(CGroup::class)->find($gid) : null;
65+
66+
$docLink = $this->linkRepo->findParentLinkForContext(
67+
$docNode,
68+
$course,
69+
$session,
70+
$group,
71+
null,
72+
null
73+
);
74+
75+
if (!$docLink instanceof ResourceLink) {
76+
throw new BadRequestHttpException('Document has no link in this context.');
77+
}
78+
79+
$destLink = $this->linkRepo->findParentLinkForContext(
80+
$destNode,
81+
$course,
82+
$session,
83+
$group,
84+
null,
85+
null
86+
);
87+
88+
if (!$destLink instanceof ResourceLink) {
89+
throw new BadRequestHttpException('Destination folder has no link in this context.');
90+
}
91+
92+
if ($docLink->getId() === $destLink->getId()) {
93+
throw new BadRequestHttpException('Cannot move into itself.');
94+
}
95+
96+
$docLink->setParent($destLink);
97+
$this->em->persist($docLink);
98+
$this->em->flush();
99+
100+
return $document;
101+
}
102+
103+
$docNode->setParent($destNode);
104+
$this->em->persist($docNode);
105+
$this->em->flush();
106+
107+
return $document;
108+
}
109+
110+
private function normalizeNodeId(mixed $value): ?int
111+
{
112+
if (is_int($value)) {
113+
return $value;
114+
}
115+
116+
if (is_string($value)) {
117+
if (ctype_digit($value)) {
118+
return (int) $value;
119+
}
120+
121+
if (preg_match('#/api/resource_nodes/(\d+)#', $value, $m)) {
122+
return (int) $m[1];
123+
}
124+
}
125+
126+
return null;
127+
}
128+
}

src/CoreBundle/Migrations/Schema/V200/Version20251201164100.php renamed to src/CoreBundle/Migrations/Schema/V200/Version20250922120000.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
1010
use Doctrine\DBAL\Schema\Schema;
1111

12-
final class Version20251201164100 extends AbstractMigrationChamilo
12+
final class Version20250922120000 extends AbstractMigrationChamilo
1313
{
1414
public function getDescription(): string
1515
{

0 commit comments

Comments
 (0)