Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions assets/vue/services/courseMaintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,11 @@ async function recycleCourse(node = resolveNodeFromPath(), payload) {
const resp = await http.post(base.recycleCourse(node), payload, { params: withCourseParams() })
return resp.data
}
async function deleteCourse(node = resolveNodeFromPath(), confirmText) {
const resp = await http.post(base.deleteCourse(node), { confirm: confirmText }, { params: withCourseParams() })
async function deleteCourse(node = resolveNodeFromPath(), payloadOrConfirm) {
const payload = typeof payloadOrConfirm === "string" ? { confirm: payloadOrConfirm } : payloadOrConfirm || {}

const resp = await http.post(base.deleteCourse(node), payload, { params: withCourseParams() })

return resp.data
}

Expand Down
82 changes: 70 additions & 12 deletions assets/vue/views/coursemaintenance/DeleteCourse.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,82 @@
<template>
<div class="space-y-6">
<CMAlert type="error" :text="t('Danger zone: deleting a course is permanent.')" />
<CMAlert
type="error"
:text="t('Danger zone: deleting a course is permanent.')"
/>

<section class="rounded-lg border border-rose-200 bg-rose-50 p-4">
<h3 class="mb-2 text-sm font-semibold text-rose-900">{{ t("Confirm deletion") }}</h3>
<h3 class="mb-2 text-sm font-semibold text-rose-900">
{{ t("Confirm deletion") }}
</h3>
<p class="mb-3 text-sm text-rose-800">
{{ t("Type the course code to confirm. All data will be permanently removed.") }}
</p>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-rose-900">{{ t("Course code") }}</label>
<label class="mb-1 block text-xs font-medium text-rose-900">
{{ t("Course code") }}
</label>
<input
v-model="confirmText"
class="w-full rounded border border-rose-300 p-2 text-sm"
:placeholder="courseCode || 'ABC101'"
/>
<p v-if="confirmText && !canDelete" class="mt-1 text-xs text-rose-700">
<p
v-if="confirmText && !canDelete"
class="mt-1 text-xs text-rose-700"
>
{{ t("The code must match exactly:") }} <strong>{{ courseCode }}</strong>
</p>

<!-- Extra option: delete orphan documents too -->
<div class="mt-3 flex items-start gap-2 text-xs text-rose-900">
<input
id="delete-docs"
v-model="deleteDocs"
type="checkbox"
class="mt-[2px] h-4 w-4 rounded border-rose-300"
/>
<label
for="delete-docs"
class="select-none"
>
{{ t("Also delete documents that are only used in this course (if any).") }}
<span class="block text-[11px] text-rose-700">
{{
t(
"If unchecked, those files will remain available to the platform administrator through the 'File information' tool.",
)
}}
</span>
</label>
</div>
</div>

<div class="flex items-end">
<button class="btn-danger" :disabled="loading || !canDelete" @click="submit">
<i class="mdi mdi-delete-alert"></i> {{ t("Delete course") }}
<button
class="btn-danger"
:disabled="loading || !canDelete"
@click="submit"
>
<i class="mdi mdi-delete-alert"></i>
{{ t("Delete course") }}
</button>
</div>
</div>
</section>

<CMAlert v-if="error" type="error" :text="error" />
<CMAlert v-if="notice" type="success" :text="notice" />
<CMAlert
v-if="error"
type="error"
:text="error"
/>
<CMAlert
v-if="notice"
type="success"
:text="notice"
/>
<CMLoader v-if="loading" />
</div>
</template>
Expand All @@ -47,11 +94,11 @@ const route = useRoute()
const node = ref(Number(route.params.node || 0))

const confirmText = ref("")
const deleteDocs = ref(false)
const loading = ref(false)
const error = ref("")
const notice = ref("")

// Read current course from Pinia (header ya lo muestra)
const cidReq = useCidReqStore()
const { course } = storeToRefs(cidReq)
const courseCode = computed(() => String(course?.value?.code || ""))
Expand All @@ -61,12 +108,23 @@ const canDelete = computed(() => !!confirmText.value && confirmText.value === co

async function submit() {
if (!confirm(t("This action cannot be undone. Continue?"))) return
error.value = ""; notice.value = ""

error.value = ""
notice.value = ""

try {
loading.value = true
const res = await svc.deleteCourse(node.value, confirmText.value)

const payload = {
confirm: confirmText.value,
delete_docs: deleteDocs.value ? 1 : 0,
}

const res = await svc.deleteCourse(node.value, payload)
notice.value = res.message || t("Course deleted successfully.")
if (res.redirectUrl) window.location.href = res.redirectUrl
if (res.redirectUrl) {
window.location.href = res.redirectUrl
}
} catch (e) {
error.value = e?.response?.data?.error || t("Failed to delete course.")
} finally {
Expand Down
134 changes: 126 additions & 8 deletions public/main/admin/course_list.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ function get_course_data(
),
$path.'course_copy/create_backup.php?'.api_get_cidreq_params($courseId)
);

// Single course delete: ask if exclusive documents should also be removed.
$actions[] = Display::url(
Display::getMdiIcon(
ActionIcon::DELETE,
Expand All @@ -251,12 +253,12 @@ function get_course_data(
$path.'admin/course_list.php?'
.http_build_query([
'delete_course' => $course['col0'],
// Default: keep documents; JS will toggle this param to 1 if admin agrees.
'delete_docs' => 0,
'sec_token' => Security::getTokenFromSession(),
]),
[
'onclick' => "javascript: if (!confirm('"
.addslashes(api_htmlentities(get_lang('Please confirm your choice'), \ENT_QUOTES))
."')) return false;",
'onclick' => 'return confirmDeleteCourseWithDocs(this);',
]
);

Expand Down Expand Up @@ -361,9 +363,11 @@ function get_course_visibility_icon(int $visibility): string
if ('delete_courses' == $_POST['action']) {
if (!empty($_POST['course'])) {
$course_codes = $_POST['course'];
$deleteDocs = isset($_POST['delete_docs']) && (int) $_POST['delete_docs'] === 1;

if (count($course_codes) > 0) {
foreach ($course_codes as $course_code) {
CourseManager::delete_course($course_code);
CourseManager::delete_course($course_code, $deleteDocs);
}
}

Expand Down Expand Up @@ -459,8 +463,11 @@ function get_course_visibility_icon(int $visibility): string
$content .= $form->returnForm();
} else {
$tool_name = get_lang('Course list');

// Single course deletion (from action icon)
if (isset($_GET['delete_course']) && Security::check_token('get')) {
$result = CourseManager::delete_course($_GET['delete_course']);
$deleteDocs = isset($_GET['delete_docs']) && (int) $_GET['delete_docs'] === 1;
$result = CourseManager::delete_course($_GET['delete_course'], $deleteDocs);
if ($result) {
Display::addFlash(Display::return_message(get_lang('Deleted')));
}
Expand Down Expand Up @@ -573,6 +580,7 @@ function get_course_visibility_icon(int $visibility): string
</script>';

$actions = Display::toolbarAction('toolbar', [$actions1, $actions3.$actions4.$actions2]);

// Create a sortable table with the course data
$table = new SortableTable(
'courses',
Expand All @@ -584,10 +592,12 @@ function get_course_visibility_icon(int $visibility): string
'course-list'
);

$parameters = [];
$parameters['sec_token'] = Security::get_token();
$parameters = [
'sec_token' => Security::get_token(),
];

if (isset($_GET['keyword'])) {
$parameters = ['keyword' => Security::remove_XSS($_GET['keyword'])];
$parameters['keyword'] = Security::remove_XSS($_GET['keyword']);
} elseif (isset($_GET['keyword_code'])) {
$parameters['keyword_code'] = Security::remove_XSS($_GET['keyword_code']);
$parameters['keyword_title'] = Security::remove_XSS($_GET['keyword_title']);
Expand Down Expand Up @@ -624,6 +634,114 @@ function get_course_visibility_icon(int $visibility): string
$tab = CourseManager::getCourseListTabs('simple');

$content .= $tab.$table->return_table();

// JS helper to ask for exclusive document deletion both for single and bulk delete.
$deleteDocsMessage = addslashes(
get_lang(
'When deleting a course or multiple selected courses, any documents that are only used in those course(s) (if any) will normally be kept as orphan files and will remain visible in the "File information" tool (platform admin only). Click "OK" if you also want to permanently delete those orphan files from disk; click "Cancel" to keep them as orphan files.'
)
);

// Fallback confirmation text; SortableTable uses data-confirm on the link.
$baseConfirmMessage = addslashes(get_lang('Please confirm your choice'));

$content .= '<script>
(function () {
var docsMsg = "'.$deleteDocsMessage.'";
var defaultConfirmMsg = "'.$baseConfirmMessage.'";

// Single-course delete (trash icon per row)
window.confirmDeleteCourseWithDocs = function (link) {
var baseMsg = link.getAttribute("data-confirm") || defaultConfirmMsg;

// Confirm course deletion.
if (!window.confirm(baseMsg)) {
return false;
}

// Ask about orphan documents on disk.
if (window.confirm(docsMsg)) {
if (link.href.indexOf("delete_docs=0") !== -1) {
link.href = link.href.replace("delete_docs=0", "delete_docs=1");
} else if (link.href.indexOf("delete_docs=") === -1) {
var sep = link.href.indexOf("?") === -1 ? "?" : "&";
link.href = link.href + sep + "delete_docs=1";
}
}

return true;
};

// Bulk delete (SortableTable dropdown)
function wrapActionClick() {
// Ensure we only wrap once and only if action_click exists.
if (!window.action_click || window.action_click.__wrappedForCourseList) {
return;
}

var originalActionClick = window.action_click;
var docsMsgLocal = docsMsg;

window.action_click = function (el, formId) {
var action = el.getAttribute("data-action");
var confirmMsg = el.getAttribute("data-confirm") || defaultConfirmMsg;

// Intercept only the bulk delete of this page.
if (formId === "form_courses_id" && action === "delete_courses") {
var form = document.getElementById(formId);
if (!form) {
return false;
}

// 1) Confirm deletion of selected courses.
if (confirmMsg && !window.confirm(confirmMsg)) {
return false;
}

// 2) Ask if orphan documents should also be deleted from disk.
var deleteDocs = window.confirm(docsMsgLocal);

// Ensure "action" hidden field exists and is set.
var actionInput = form.querySelector(\'input[name="action"]\');
if (!actionInput) {
actionInput = document.createElement("input");
actionInput.type = "hidden";
actionInput.name = "action";
form.appendChild(actionInput);
}
actionInput.value = action;

// If user accepted the docs deletion, set the delete_docs flag.
if (deleteDocs) {
var deleteDocsInput = form.querySelector(\'input[name="delete_docs"]\');
if (!deleteDocsInput) {
deleteDocsInput = document.createElement("input");
deleteDocsInput.type = "hidden";
deleteDocsInput.name = "delete_docs";
form.appendChild(deleteDocsInput);
}
deleteDocsInput.value = "1";
}

form.submit();
return false;
}

// Fallback: keep original behavior for any other action/form.
return originalActionClick(el, formId);
};

window.action_click.__wrappedForCourseList = true;
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wrapActionClick);
} else {
wrapActionClick();
}
})();
</script>';

}

$tpl = new Template($tool_name);
Expand Down
Loading
Loading