Compare commits
288 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7ff1dff3 | ||
|
|
9ac4efe9dc | ||
|
|
e278e1ed35 | ||
|
|
9db203d74f | ||
|
|
c6366835d2 | ||
|
|
5e8ad81ff3 | ||
|
|
ac24a353b0 | ||
|
|
8a3c681a6f | ||
|
|
2da946236d | ||
|
|
d4641c9135 | ||
|
|
cf710d7be5 | ||
|
|
e56b8928f7 | ||
|
|
66121e6cce | ||
|
|
cd824631bb | ||
|
|
115b72f2f0 | ||
|
|
8d17b35160 | ||
|
|
4c21ce2caa | ||
|
|
0057467acf | ||
|
|
7048b22df0 | ||
|
|
ddc3352b4b | ||
|
|
060a2808de | ||
|
|
d8f8a8e559 | ||
|
|
c471d39ba8 | ||
|
|
55ec813f82 | ||
|
|
727f7b032c | ||
|
|
d1b613c0bb | ||
|
|
c3af65e535 | ||
|
|
d688d5cdd9 | ||
|
|
97543a43eb | ||
|
|
0e6df83961 | ||
|
|
6329d9c917 | ||
|
|
015e228304 | ||
|
|
a9f40d16f0 | ||
|
|
b8da14a32e | ||
|
|
a64b0f734a | ||
|
|
34ba2fb361 | ||
|
|
98ccb15796 | ||
|
|
6c06f7d19b | ||
|
|
86b129a25f | ||
|
|
6e8d4cd8e8 | ||
|
|
1b4622bdb2 | ||
|
|
58d51579e3 | ||
|
|
06706ea41b | ||
|
|
d634a0f784 | ||
|
|
a92159b811 | ||
|
|
7e1e37393c | ||
|
|
d2f9a2cea4 | ||
|
|
5111d83eee | ||
|
|
0dc77343c4 | ||
|
|
cec5913632 | ||
|
|
75d43a1563 | ||
|
|
1ecdbd9e06 | ||
|
|
a90e3d611c | ||
|
|
d49d638253 | ||
|
|
83338a56c0 | ||
|
|
562020de70 | ||
|
|
044907edeb | ||
|
|
cfa1aa87fc | ||
|
|
0ac32ee474 | ||
|
|
de0675f850 | ||
|
|
1c529790f2 | ||
|
|
40bcc4d572 | ||
|
|
58f109e79c | ||
|
|
cb324f6269 | ||
|
|
7cafaf5cbc | ||
|
|
a394952630 | ||
|
|
68e87f20aa | ||
|
|
64ed0b3e94 | ||
|
|
fcaaee958d | ||
|
|
29e356ff86 | ||
|
|
460edc7bc7 | ||
|
|
582c7af12d | ||
|
|
af533a7a2c | ||
|
|
acbede157f | ||
|
|
8e1db293db | ||
|
|
f63a627ff2 | ||
|
|
b1a0556c12 | ||
|
|
0097ede6ed | ||
|
|
b72774e54d | ||
|
|
08261c804f | ||
|
|
3027a9e523 | ||
|
|
c3995952b3 | ||
|
|
ff1642382c | ||
|
|
cfe35e40da | ||
|
|
c3238a9f91 | ||
|
|
58f08bf065 | ||
|
|
d3ac6ea337 | ||
|
|
6649b7955f | ||
|
|
15a53d33e0 | ||
|
|
57f09542a2 | ||
|
|
fa384b391d | ||
|
|
12b138c39f | ||
|
|
420a5f39eb | ||
|
|
12c2666bd1 | ||
|
|
1ecbc2e3f9 | ||
|
|
e1a78382c3 | ||
|
|
dcf5c72cad | ||
|
|
2ebf6be609 | ||
|
|
4ce7019ce6 | ||
|
|
3faf814162 | ||
|
|
52bd9825d8 | ||
|
|
b6028e741c | ||
|
|
4ee1693434 | ||
|
|
cbc7892b25 | ||
|
|
a4fa2ef0b3 | ||
|
|
96de90cb5f | ||
|
|
dfb22c81c3 | ||
|
|
6a70ed18d8 | ||
|
|
629c237349 | ||
|
|
cf014bca3c | ||
|
|
9323d8e17d | ||
|
|
1ba63a2175 | ||
|
|
b5551fd8ba | ||
|
|
fac0038af8 | ||
|
|
ee6685e324 | ||
|
|
0fb18f995c | ||
|
|
61e13aa7cd | ||
|
|
acb8c6c500 | ||
|
|
af838121d9 | ||
|
|
f504841a5c | ||
|
|
fb3d8e4f7d | ||
|
|
be49ba6d04 | ||
|
|
24ffed11fb | ||
|
|
73754bd104 | ||
|
|
0c6029cbe8 | ||
|
|
a643e9ae83 | ||
|
|
08ac3948c3 | ||
|
|
78d289b9c0 | ||
|
|
3473bdb527 | ||
|
|
a7f8835222 | ||
|
|
d6441955fc | ||
|
|
67d265e864 | ||
|
|
17031f1df0 | ||
|
|
234a24baa2 | ||
|
|
9a58f4688b | ||
|
|
87c1c928ba | ||
|
|
493b8297ea | ||
|
|
4d16602190 | ||
|
|
89222b23c3 | ||
|
|
89a181c7d5 | ||
|
|
c0aecf30c1 | ||
|
|
fc8ef21802 | ||
|
|
2e1aac4931 | ||
|
|
93b3eda05c | ||
|
|
740584d883 | ||
|
|
c45da4313e | ||
|
|
3a1a843747 | ||
|
|
5e6160149f | ||
|
|
be66c563a8 | ||
|
|
92c380c74b | ||
|
|
c51e7b0037 | ||
|
|
e25f161980 | ||
|
|
000d9dbcef | ||
|
|
822603128d | ||
|
|
9dbe8fbb1f | ||
|
|
26f1e228a9 | ||
|
|
0dcfd7e482 | ||
|
|
e933012a34 | ||
|
|
71db3ae6da | ||
|
|
c5f091fae8 | ||
|
|
4e61d569ac | ||
|
|
2d5c76e106 | ||
|
|
2e0abad61c | ||
|
|
3ea52a4e41 | ||
|
|
c05e253b8d | ||
|
|
08b2063e45 | ||
|
|
4a8c8185c2 | ||
|
|
74ed7b3160 | ||
|
|
38e6e4345f | ||
|
|
8004982e2e | ||
|
|
e6a532a870 | ||
|
|
f90465210e | ||
|
|
4b3a71e424 | ||
|
|
5499e7294d | ||
|
|
619262aa97 | ||
|
|
693d2942aa | ||
|
|
b4cf62920c | ||
|
|
03636d6930 | ||
|
|
7c1e1c86c7 | ||
|
|
8a5eceaf05 | ||
|
|
720425d1fb | ||
|
|
1f105b9ae5 | ||
|
|
d43442be5c | ||
|
|
3360b114b4 | ||
|
|
94835b4117 | ||
|
|
e6ed0b21e5 | ||
|
|
37db021682 | ||
|
|
6014a5ccce | ||
|
|
c07207b564 | ||
|
|
fe1f78f8aa | ||
|
|
1709c6b658 | ||
|
|
d3583a2cfb | ||
|
|
634035fbc0 | ||
|
|
3c5b18411b | ||
|
|
82bb45a9ef | ||
|
|
373f3df196 | ||
|
|
6021f15bac | ||
|
|
da71fb2c23 | ||
|
|
8f6f35d7c1 | ||
|
|
7aa5f4d20b | ||
|
|
64b54b05a6 | ||
|
|
22b1f22df4 | ||
|
|
ae4e5539d7 | ||
|
|
dbd96329b5 | ||
|
|
c118ec7c4a | ||
|
|
7aab449502 | ||
|
|
cf166b3a57 | ||
|
|
da5910d40d | ||
|
|
8640ecf9be | ||
|
|
c4faceff30 | ||
|
|
01bd017bda | ||
|
|
d76357981b | ||
|
|
19b759e9fb | ||
|
|
df3bca6405 | ||
|
|
5cde79b5eb | ||
|
|
9b35cdbddc | ||
|
|
70ec22004a | ||
|
|
95ed77421a | ||
|
|
d64ec9817c | ||
|
|
ce01b7634f | ||
|
|
e0819f83bc | ||
|
|
f87d28c2f5 | ||
|
|
544b59744b | ||
|
|
467dfb831d | ||
|
|
4c4b4eaf55 | ||
|
|
227e5d00e5 | ||
|
|
73e9e384c8 | ||
|
|
5bebdcba68 | ||
|
|
1c2e52ae4b | ||
|
|
9377e89561 | ||
|
|
4cae05ecbe | ||
|
|
909dcfd51e | ||
|
|
2bd96a1f2a | ||
|
|
aca41080ee | ||
|
|
1c351696a9 | ||
|
|
51a8958aa6 | ||
|
|
777b8aed02 | ||
|
|
3672b90075 | ||
|
|
92c7e613db | ||
|
|
5c58b85a00 | ||
|
|
8af82daa37 | ||
|
|
224bb18d3e | ||
|
|
aab7bdcc20 | ||
|
|
c5ca428d98 | ||
|
|
af0cc7126b | ||
|
|
a085050d27 | ||
|
|
2442f35f56 | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
53285a0d19 | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a |
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://test_site_ui:8000",
|
||||
baseUrl: "http://lms1:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a").contains("New Course").click();
|
||||
cy.get("header").children().last().children().last().click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("Course Creation", () => {
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
cy.button("Create").click();
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.4.2",
|
||||
"ace-builds": "^1.36.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.69",
|
||||
"frappe-ui": "^0.1.72",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Avatar } from 'frappe-ui'
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -35,24 +35,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createListResource({
|
||||
doctype: 'Communication',
|
||||
fields: [
|
||||
'subject',
|
||||
'content',
|
||||
'recipients',
|
||||
'cc',
|
||||
'communication_date',
|
||||
'sender',
|
||||
'sender_full_name',
|
||||
],
|
||||
filters: {
|
||||
reference_doctype: 'LMS Batch',
|
||||
reference_name: props.batch,
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
orderBy: 'communication_date desc',
|
||||
auto: true,
|
||||
cache: ['batch', props.batch],
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
||||
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown :isCollapsed="isSidebarCollapsed" />
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<SidebarLink
|
||||
v-for="link in sidebarLinks"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
/>
|
||||
</div>
|
||||
@@ -22,11 +22,11 @@
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
@click="showWebPages = !showWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed"
|
||||
v-if="!sidebarStore.isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-gray-600 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
@@ -53,7 +53,7 @@
|
||||
<SidebarLink
|
||||
v-for="link in sidebarSettings.data.web_pages"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@@ -64,17 +64,19 @@
|
||||
</div>
|
||||
<SidebarLink
|
||||
:link="{
|
||||
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||
}"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
@click="isSidebarCollapsed = !isSidebarCollapsed"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
@click="toggleSidebar()"
|
||||
class="m-2"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<CollapseSidebar
|
||||
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
|
||||
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
|
||||
:class="{
|
||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -96,19 +98,24 @@ import { ref, onMounted, inject, watch } from 'vue'
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import PageModal from '@/components/Modals/PageModal.vue'
|
||||
|
||||
const { user, sidebarSettings } = sessionStore()
|
||||
const { userResource } = usersStore()
|
||||
let sidebarStore = useSidebar()
|
||||
const socket = inject('$socket')
|
||||
const unreadCount = ref(0)
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const showPageModal = ref(false)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
const pageToEdit = ref(null)
|
||||
const showWebPages = ref(false)
|
||||
const settingsStore = useSettings()
|
||||
|
||||
onMounted(() => {
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
@@ -167,6 +174,48 @@ const addNotifications = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: ['Quizzes', 'QuizForm'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addPrograms = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let index = 1
|
||||
let canAddProgram = false
|
||||
|
||||
if (
|
||||
!isInstructor.value &&
|
||||
!isModerator.value &&
|
||||
settingsStore.learningPaths.data
|
||||
) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label !== 'Courses'
|
||||
)
|
||||
activeFor.push('CourseDetail')
|
||||
activeFor.push('Lesson')
|
||||
index = 0
|
||||
canAddProgram = true
|
||||
} else if (isInstructor.value || isModerator.value) {
|
||||
canAddProgram = true
|
||||
}
|
||||
|
||||
if (canAddProgram) {
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openPageModal = (link) => {
|
||||
showPageModal.value = true
|
||||
pageToEdit.value = link
|
||||
@@ -197,8 +246,13 @@ const getSidebarFromStorage = () => {
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addQuizzes()
|
||||
addPrograms()
|
||||
}
|
||||
})
|
||||
|
||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||
const toggleSidebar = () => {
|
||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -160,7 +160,7 @@ const getRowRoute = (row) => {
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'Quiz',
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: row.assessment_name,
|
||||
},
|
||||
|
||||
@@ -56,7 +56,6 @@ const props = defineProps({
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
audio.value = document.querySelector('audio')
|
||||
console.log(audio.value)
|
||||
audio.value.onloadedmetadata = () => {
|
||||
duration.value = audio.value.duration
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
@@ -97,11 +98,13 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button } from 'frappe-ui'
|
||||
import { Badge, Button, createResource } from 'frappe-ui'
|
||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
@@ -111,6 +114,39 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const enroll = createResource({
|
||||
url: 'lms.lms.utils.enroll_in_batch',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch: props.batch.data.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this batch'),
|
||||
'check'
|
||||
)
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: props.batch.data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const seats_left = computed(() => {
|
||||
if (props.batch.data?.seat_count) {
|
||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex flex-col justify-between min-h-0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
@@ -16,11 +16,13 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,9 +72,16 @@ const update = () => {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
saveSettings.submit({
|
||||
fields: fieldsToSave,
|
||||
})
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="option.label != option.description"
|
||||
v-if="option.description"
|
||||
class="text-xs text-gray-700"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
|
||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div
|
||||
class="editor flex flex-col gap-1"
|
||||
:style="{
|
||||
height: height,
|
||||
}"
|
||||
>
|
||||
<span class="text-xs" v-if="label">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
ref="editor"
|
||||
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
||||
/>
|
||||
<span
|
||||
class="mt-1 text-xs text-gray-600"
|
||||
v-show="description"
|
||||
v-html="description"
|
||||
></span>
|
||||
<Button
|
||||
v-if="showSaveButton"
|
||||
@click="emit('save', aceEditor?.getValue())"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import ace from 'ace-builds'
|
||||
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
||||
import 'ace-builds/src-min-noconflict/theme-chrome'
|
||||
import 'ace-builds/src-min-noconflict/theme-twilight'
|
||||
import { PropType, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const isDark = useDark({
|
||||
attribute: 'data-theme',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, String, Array],
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
||||
default: 'JSON',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '250px',
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSaveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'update:modelValue'])
|
||||
const editor = ref<HTMLElement | null>(null)
|
||||
let aceEditor = null as ace.Ace.Editor | null
|
||||
|
||||
onMounted(() => {
|
||||
setupEditor()
|
||||
})
|
||||
|
||||
const setupEditor = () => {
|
||||
aceEditor = ace.edit(editor.value as HTMLElement)
|
||||
resetEditor(props.modelValue as string, true)
|
||||
aceEditor.setReadOnly(props.readonly)
|
||||
aceEditor.setOptions({
|
||||
fontSize: '12px',
|
||||
useWorker: false,
|
||||
showGutter: props.showLineNumbers,
|
||||
wrap: props.showLineNumbers,
|
||||
})
|
||||
if (props.type === 'CSS') {
|
||||
import('ace-builds/src-noconflict/mode-css').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/css')
|
||||
})
|
||||
} else if (props.type === 'JavaScript') {
|
||||
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/javascript')
|
||||
})
|
||||
} else if (props.type === 'Python') {
|
||||
import('ace-builds/src-noconflict/mode-python').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/python')
|
||||
})
|
||||
} else if (props.type === 'JSON') {
|
||||
import('ace-builds/src-noconflict/mode-json').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/json')
|
||||
})
|
||||
} else {
|
||||
import('ace-builds/src-noconflict/mode-html').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/html')
|
||||
})
|
||||
}
|
||||
aceEditor.on('blur', () => {
|
||||
try {
|
||||
let value = aceEditor?.getValue() || ''
|
||||
if (props.type === 'JSON') {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
if (value === props.modelValue) return
|
||||
if (!props.showSaveButton && !props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getModelValue = () => {
|
||||
let value = props.modelValue || ''
|
||||
try {
|
||||
if (props.type === 'JSON' || typeof value === 'object') {
|
||||
value = JSON.stringify(value, null, 2)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
return value as string
|
||||
}
|
||||
|
||||
function resetEditor(value: string, resetHistory = false) {
|
||||
value = getModelValue()
|
||||
aceEditor?.setValue(value)
|
||||
aceEditor?.clearSelection()
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
props.autofocus && aceEditor?.focus()
|
||||
if (resetHistory) {
|
||||
aceEditor?.session.getUndoManager().reset()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
setupEditor()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
resetEditor(props.modelValue as string)
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ resetEditor })
|
||||
</script>
|
||||
<style scoped>
|
||||
.editor .ace_editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.editor :deep(.ace_scrollbar-h) {
|
||||
display: none;
|
||||
}
|
||||
.editor :deep(.ace_search) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_searchbtn) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_button) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.editor :deep(.ace_search_field) {
|
||||
@apply dark:bg-gray-900 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||
{{ attrs.label }}
|
||||
<span class="text-red-500" v-if="attrs.required">*</span>
|
||||
</label>
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
@@ -43,6 +44,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +68,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
@@ -117,7 +123,7 @@ const options = createResource({
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
return {
|
||||
label: option.value,
|
||||
label: option.label || option.value,
|
||||
value: option.value,
|
||||
description: option.description,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
{{ label }}
|
||||
<span class="text-red-500" v-if="required">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<Button
|
||||
@@ -115,6 +116,9 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
@@ -152,24 +156,11 @@ const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
/* transform: (data) => {
|
||||
let allData = data
|
||||
.filter((c) => {
|
||||
return c.description.split(', ')[1]
|
||||
})
|
||||
.map((option) => {
|
||||
let email = option.description.split(', ')[1]
|
||||
return {
|
||||
label: option.label || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
return allData
|
||||
}, */
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
||||
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
|
||||
>
|
||||
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||
{{ __('Featured') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
size="md"
|
||||
v-for="tag in course.tags"
|
||||
@@ -30,29 +30,29 @@
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.lesson_count }}
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count">
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.enrollment_count }}
|
||||
{{ course.enrollments }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating">
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.avg_rating }}
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -93,21 +93,19 @@
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{ __('Enrolled Students') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.avg_rating }} {{ __('Rating') }}
|
||||
</span>
|
||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,7 +114,7 @@
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { createToast } from '@/utils/'
|
||||
import { showToast, formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -139,11 +137,11 @@ const video_link = computed(() => {
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
createToast({
|
||||
title: 'Please Login',
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
||||
})
|
||||
showToast(
|
||||
__('Please Login'),
|
||||
__('You need to login first to enroll for this course'),
|
||||
'alert-circle'
|
||||
)
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 2000)
|
||||
@@ -159,11 +157,11 @@ function enrollStudent() {
|
||||
capture('enrolled_in_course', {
|
||||
course: props.course.data.name,
|
||||
})
|
||||
createToast({
|
||||
title: 'Enrolled Successfully',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600 bg-green-100',
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this course'),
|
||||
'check'
|
||||
)
|
||||
setTimeout(() => {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
@@ -173,7 +171,7 @@ function enrollStudent() {
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -206,7 +204,6 @@ const certificate = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
console.log(data)
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
data.name
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<span v-if="instructors.length == 1">
|
||||
<span v-if="instructors?.length == 1">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ instructors[0].full_name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="instructors.length == 2">
|
||||
<span v-if="instructors?.length == 2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -28,7 +28,7 @@
|
||||
{{ instructors[1].first_name }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="instructors.length > 2">
|
||||
<span v-if="instructors?.length > 2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -37,7 +37,7 @@
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
and {{ instructors.length - 1 }} others
|
||||
and {{ instructors?.length - 1 }} others
|
||||
</span>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
|
||||
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||
}"
|
||||
>
|
||||
<Disclosure
|
||||
@@ -25,21 +25,42 @@
|
||||
:key="chapter.name"
|
||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||
>
|
||||
<DisclosureButton ref="" class="flex w-full p-2">
|
||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
hidden: chapter.is_scorm_package,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
class="h-4 w-4 text-gray-900 stroke-1"
|
||||
/>
|
||||
<div class="text-base text-left font-medium leading-5">
|
||||
<div
|
||||
class="text-base text-left font-medium leading-5 ml-2"
|
||||
@click="redirectToChapter(chapter)"
|
||||
>
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<div class="flex ml-auto space-x-4">
|
||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||
<FilePenLine
|
||||
v-if="allowEdit"
|
||||
@click.prevent="openChapterModal(chapter)"
|
||||
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Delete Chapter')" placement="bottom">
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashChapter(chapter.name)"
|
||||
class="h-4 w-4 text-red-500 invisible group-hover:visible"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:list="chapter.lessons"
|
||||
:disabled="!allowEdit"
|
||||
item-key="name"
|
||||
@@ -76,7 +97,7 @@
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
||||
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
@@ -89,6 +110,7 @@
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<router-link
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
@@ -102,9 +124,6 @@
|
||||
{{ __('Add Lesson') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button class="ml-2" @click="openChapterModal(chapter)">
|
||||
{{ __('Edit Chapter') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
@@ -118,26 +137,30 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||
import { getCurrentInstance, inject, ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
ChevronRight,
|
||||
MonitorPlay,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
const expandAll = ref(true)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showChapterModal = ref(false)
|
||||
const currentChapter = ref(null)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -202,9 +225,25 @@ const updateLessonIndex = createResource({
|
||||
})
|
||||
|
||||
const trashLesson = (lessonName, chapterName) => {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
$dialog({
|
||||
title: __('Delete this lesson?'),
|
||||
message: __(
|
||||
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,6 +268,61 @@ const updateOutline = (e) => {
|
||||
idx: e.newIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteChapter = createResource({
|
||||
url: 'lms.lms.api.delete_chapter',
|
||||
makeParams(values) {
|
||||
return {
|
||||
chapter: values.chapter,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
outline.reload()
|
||||
showToast('Success', 'Chapter deleted successfully', 'check')
|
||||
},
|
||||
})
|
||||
|
||||
const trashChapter = (chapterName) => {
|
||||
$dialog({
|
||||
title: __('Delete this chapter?'),
|
||||
message: __(
|
||||
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
deleteChapter.submit({ chapter: chapterName })
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const redirectToChapter = (chapter) => {
|
||||
if (!chapter.is_scorm_package) return
|
||||
event.preventDefault()
|
||||
if (props.allowEdit) return
|
||||
if (!user.data) {
|
||||
showToast(
|
||||
__('You are not enrolled'),
|
||||
__('Please enroll for this course to view this lesson'),
|
||||
'alert-circle'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
name: 'SCORMChapter',
|
||||
params: {
|
||||
courseName: props.courseName,
|
||||
chapterName: chapter.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.outline-lesson:has(.router-link-active) {
|
||||
|
||||
@@ -76,7 +76,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-for="block in content.split('\n\n')">
|
||||
<div v-for="block in content?.split('\n\n')">
|
||||
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||
<iframe
|
||||
class="youtube-video"
|
||||
@@ -37,7 +37,7 @@
|
||||
<iframe
|
||||
:src="getPDFSource(block)"
|
||||
width="100%"
|
||||
height="400"
|
||||
height="700px"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
||||
@click="openHelpDialog('upload')"
|
||||
>
|
||||
<span class="leading-5">
|
||||
{{ __('How to upload content from your system?') }}
|
||||
{{ __(contentMap['upload']) }}
|
||||
</span>
|
||||
<Info class="w-3 h-3 text-gray-700" />
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
@click="openHelpDialog('youtube')"
|
||||
>
|
||||
<span>
|
||||
{{ __('How to add a YouTube Video?') }}
|
||||
{{ __(contentMap['youtube']) }}
|
||||
</span>
|
||||
<Info class="w-3 h-3 text-gray-700" />
|
||||
</div>
|
||||
@@ -56,8 +56,23 @@
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExplanationVideos v-model="showExplanation" :type="type" />
|
||||
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Info } from 'lucide-vue-next'
|
||||
@@ -66,9 +81,16 @@ import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
|
||||
|
||||
const showExplanation = ref(false)
|
||||
const type = ref(null)
|
||||
const title = ref(null)
|
||||
const contentMap = {
|
||||
quiz: 'How to add a Quiz?',
|
||||
upload: 'How to upload content from your system?',
|
||||
youtube: 'How to add a YouTube Video?',
|
||||
}
|
||||
|
||||
const openHelpDialog = (contentType) => {
|
||||
type.value = contentType
|
||||
title.value = contentMap[contentType]
|
||||
showExplanation.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
@@ -45,9 +46,10 @@
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data"
|
||||
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${
|
||||
sidebarLinks.length + 1
|
||||
}, minmax(0, 1fr))`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
@@ -23,15 +25,46 @@
|
||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||
/>
|
||||
</button>
|
||||
<Popover
|
||||
trigger="hover"
|
||||
popoverClass="bottom-28 mx-2"
|
||||
placement="top-start"
|
||||
>
|
||||
<template #target>
|
||||
<component
|
||||
:is="icons['List']"
|
||||
class="h-6 w-6 stroke-1.5 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="text-base p-5 space-y-4">
|
||||
<div
|
||||
v-for="link in otherLinks"
|
||||
:key="link.label"
|
||||
class="flex items-center space-x-2"
|
||||
@click="handleClick(link)"
|
||||
>
|
||||
<component
|
||||
:is="icons[link.icon]"
|
||||
class="h-4 w-4 stroke-1.5 text-gray-600"
|
||||
/>
|
||||
<div>
|
||||
{{ link.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
const { logout, user, sidebarSettings } = sessionStore()
|
||||
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const otherLinks = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
@@ -52,37 +86,53 @@ onMounted(() => {
|
||||
)
|
||||
}
|
||||
})
|
||||
addAccessLinks()
|
||||
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const addAccessLinks = () => {
|
||||
const addOtherLinks = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Profile',
|
||||
icon: 'UserRound',
|
||||
activeFor: [
|
||||
'Profile',
|
||||
'ProfileAbout',
|
||||
'ProfileCertification',
|
||||
'ProfileEvaluator',
|
||||
'ProfileRoles',
|
||||
],
|
||||
})
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Log out',
|
||||
icon: 'LogOut',
|
||||
})
|
||||
} else {
|
||||
sidebarLinks.value.push({
|
||||
otherLinks.value.push({
|
||||
label: 'Log in',
|
||||
icon: 'LogIn',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (
|
||||
userResource.data &&
|
||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||
) {
|
||||
addQuizzes()
|
||||
}
|
||||
})
|
||||
|
||||
const addQuizzes = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
})
|
||||
}
|
||||
|
||||
let isActive = (tab) => {
|
||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Subject') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.subject" />
|
||||
</div>
|
||||
@@ -44,7 +45,7 @@
|
||||
<script setup>
|
||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { showToast } from '@/utils/'
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
@@ -94,22 +95,14 @@ const makeAnnouncement = (close) => {
|
||||
},
|
||||
onSuccess() {
|
||||
close()
|
||||
createToast({
|
||||
title: 'Success',
|
||||
text: 'Announcement has been sent successfully',
|
||||
icon: 'Check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('Announcement has been sent successfully'),
|
||||
'check'
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'check')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
|
||||
<Link
|
||||
doctype="LMS Course"
|
||||
v-model="course"
|
||||
:label="__('Course')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="Course Evaluator"
|
||||
v-model="evaluator"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Chapter'),
|
||||
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||
label: chapterDetail ? __('Edit') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: (close) =>
|
||||
chapterDetail ? editChapter(close) : addChapter(close),
|
||||
@@ -15,24 +15,77 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl
|
||||
ref="chapterInput"
|
||||
label="Title"
|
||||
v-model="chapter.title"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="space-y-4 text-base">
|
||||
<FormControl label="Title" v-model="chapter.title" :required="true" />
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('SCORM Package')"
|
||||
:description="
|
||||
__(
|
||||
'Enable this only if you want to upload a SCORM package as a chapter.'
|
||||
)
|
||||
"
|
||||
v-model="chapter.is_scorm_package"
|
||||
/>
|
||||
<div v-if="chapter.is_scorm_package">
|
||||
<FileUploader
|
||||
v-if="!chapter.scorm_package"
|
||||
:fileTypes="['.zip']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => (chapter.scorm_package = file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ chapter.scorm_package.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(chapter.scorm_package.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="() => (chapter.scorm_package = null)"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
||||
import { defineModel, reactive, watch, ref } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import {
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FileUploader,
|
||||
FormControl,
|
||||
Switch,
|
||||
} from 'frappe-ui'
|
||||
import { defineModel, reactive, watch } from 'vue'
|
||||
import { showToast, getFileSize } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const show = defineModel()
|
||||
const outline = defineModel('outline')
|
||||
const chapterInput = ref(null)
|
||||
const settingsStore = useSettings()
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
@@ -46,30 +99,19 @@ const props = defineProps({
|
||||
|
||||
const chapter = reactive({
|
||||
title: '',
|
||||
is_scorm_package: 0,
|
||||
scorm_package: null,
|
||||
})
|
||||
|
||||
const chapterResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
url: 'lms.lms.api.upsert_chapter',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Course Chapter',
|
||||
title: chapter.title,
|
||||
description: chapter.description,
|
||||
course: props.course,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const chapterEditResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Course Chapter',
|
||||
title: chapter.title,
|
||||
course: props.course,
|
||||
is_scorm_package: chapter.is_scorm_package,
|
||||
scorm_package: chapter.scorm_package,
|
||||
name: props.chapterDetail?.name,
|
||||
fieldname: 'title',
|
||||
value: chapter.title,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -89,14 +131,12 @@ const chapterReference = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const addChapter = (close) => {
|
||||
const addChapter = async (close) => {
|
||||
chapterResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!chapter.title) {
|
||||
return 'Title is required'
|
||||
}
|
||||
return validateChapter()
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
capture('chapter_created')
|
||||
@@ -104,30 +144,48 @@ const addChapter = (close) => {
|
||||
{ name: data.name },
|
||||
{
|
||||
onSuccess(data) {
|
||||
chapter.title = ''
|
||||
cleanChapter()
|
||||
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
|
||||
settingsStore.onboardingDetails.reload()
|
||||
}
|
||||
outline.value.reload()
|
||||
createToast({
|
||||
text: 'Chapter added successfully',
|
||||
icon: 'check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('Chapter added successfully'),
|
||||
'check'
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const validateChapter = () => {
|
||||
if (!chapter.title) {
|
||||
return __('Title is required')
|
||||
}
|
||||
if (chapter.is_scorm_package && !chapter.scorm_package) {
|
||||
return __('Please upload a SCORM package')
|
||||
}
|
||||
}
|
||||
|
||||
const cleanChapter = () => {
|
||||
chapter.title = ''
|
||||
chapter.is_scorm_package = 0
|
||||
chapter.scorm_package = null
|
||||
}
|
||||
|
||||
const editChapter = (close) => {
|
||||
chapterEditResource.submit(
|
||||
chapterResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
@@ -137,43 +195,29 @@ const editChapter = (close) => {
|
||||
},
|
||||
onSuccess() {
|
||||
outline.value.reload()
|
||||
createToast({
|
||||
text: 'Chapter updated successfully',
|
||||
icon: 'check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
showToast(__('Success'), __('Chapter updated successfully'), 'check')
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const showError = (err) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.chapterDetail,
|
||||
(newChapter) => {
|
||||
chapter.title = newChapter?.title
|
||||
chapter.is_scorm_package = newChapter?.is_scorm_package
|
||||
chapter.scorm_package = newChapter?.scorm_package
|
||||
}
|
||||
)
|
||||
|
||||
watch(show, () => {
|
||||
if (show.value) {
|
||||
setTimeout(() => {
|
||||
chapterInput.value.$el.querySelector('input').focus()
|
||||
}, 100)
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (extension !== 'zip') {
|
||||
return __('Only zip files are allowed')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -69,7 +69,18 @@
|
||||
:label="__('Headline')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-gray-300 hover:border-gray-400 rounded-md bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -81,6 +92,7 @@ import {
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
TextEditor,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, defineModel } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
|
||||
@@ -154,10 +154,12 @@ function submitEvaluation(close) {
|
||||
const getCourses = () => {
|
||||
let courses = []
|
||||
for (const course of props.courses) {
|
||||
courses.push({
|
||||
label: course.title,
|
||||
value: course.course,
|
||||
})
|
||||
if (course.evaluator) {
|
||||
courses.push({
|
||||
label: course.title,
|
||||
value: course.course,
|
||||
})
|
||||
}
|
||||
}
|
||||
return courses
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: title,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-4">
|
||||
<template #body-content>
|
||||
<div>
|
||||
<VideoBlock :file="file" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,6 +25,10 @@ const props = defineProps({
|
||||
type: [String, null],
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const file = computed(() => {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
v-model="liveClass.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<Tooltip
|
||||
:text="
|
||||
@@ -35,6 +36,7 @@
|
||||
type="time"
|
||||
:label="__('Time')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
@@ -42,6 +44,7 @@
|
||||
type="select"
|
||||
:options="getTimezoneOptions()"
|
||||
:label="__('Timezone')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -50,6 +53,7 @@
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:label="__('Date')"
|
||||
:required="true"
|
||||
/>
|
||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||
<FormControl
|
||||
@@ -57,6 +61,7 @@
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
@@ -156,25 +161,34 @@ const submitLiveClass = (close) => {
|
||||
return createLiveClass.submit(liveClass, {
|
||||
validate() {
|
||||
if (!liveClass.title) {
|
||||
return 'Please enter a title.'
|
||||
return __('Please enter a title.')
|
||||
}
|
||||
if (!liveClass.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
return __('Please select a date.')
|
||||
}
|
||||
if (!liveClass.time) {
|
||||
return 'Please select a time.'
|
||||
}
|
||||
if (!valideTime()) {
|
||||
return 'Please enter a valid time in the format HH:mm.'
|
||||
}
|
||||
if (!liveClass.duration) {
|
||||
return 'Please select a duration.'
|
||||
return __('Please select a time.')
|
||||
}
|
||||
if (!liveClass.timezone) {
|
||||
return 'Please select a timezone.'
|
||||
return __('Please select a timezone.')
|
||||
}
|
||||
if (!valideTime()) {
|
||||
return __('Please enter a valid time in the format HH:mm.')
|
||||
}
|
||||
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
|
||||
liveClass.timezone,
|
||||
true
|
||||
)
|
||||
if (
|
||||
liveClassDateTime.isSameOrBefore(
|
||||
dayjs().tz(liveClass.timezone, false),
|
||||
'minute'
|
||||
)
|
||||
) {
|
||||
return __('Please select a future date and time.')
|
||||
}
|
||||
if (!liveClass.duration) {
|
||||
return __('Please select a duration.')
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
id="existing"
|
||||
value="existing"
|
||||
v-model="questionType"
|
||||
class="w-3 h-3 accent-gray-900"
|
||||
class="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
<label for="existing">
|
||||
<label for="existing" class="cursor-pointer">
|
||||
{{ __('Add an existing question') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -25,9 +25,9 @@
|
||||
id="new"
|
||||
value="new"
|
||||
v-model="questionType"
|
||||
class="w-3 h-3"
|
||||
class="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
<label for="new">
|
||||
<label for="new" class="cursor-pointer">
|
||||
{{ __('Create a new question') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -54,14 +54,16 @@
|
||||
:label="__('Type')"
|
||||
v-model="question.type"
|
||||
type="select"
|
||||
:options="['Choices', 'User Input']"
|
||||
:options="['Choices', 'User Input', 'Open Ended']"
|
||||
class="pb-2"
|
||||
:required="true"
|
||||
/>
|
||||
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
||||
<div v-for="n in 4" class="space-y-4 py-2">
|
||||
<FormControl
|
||||
:label="__('Option') + ' ' + n"
|
||||
v-model="question[`option_${n}`]"
|
||||
:required="n <= 2 ? true : false"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Explanation')"
|
||||
@@ -74,10 +76,15 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else v-for="n in 4" class="space-y-2">
|
||||
<div
|
||||
v-else-if="question.type == 'User Input'"
|
||||
v-for="n in 4"
|
||||
class="space-y-2"
|
||||
>
|
||||
<FormControl
|
||||
:label="__('Possibility') + ' ' + n"
|
||||
v-model="question[`possibility_${n}`]"
|
||||
:required="n == 1 ? true : false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +130,7 @@ const populateFields = () => {
|
||||
let counter = 1
|
||||
fields.forEach((field) => {
|
||||
while (counter <= 4) {
|
||||
question[`${field}_${counter}`] = field === 'is_correct' ? false : ''
|
||||
question[`${field}_${counter}`] = field === 'is_correct' ? false : null
|
||||
counter++
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,9 +108,31 @@ const tabsStructure = computed(() => {
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description: 'Manage the members of your learning system',
|
||||
icon: 'UserRoundPlus',
|
||||
label: 'General',
|
||||
icon: 'Wrench',
|
||||
fields: [
|
||||
{
|
||||
label: 'Enable Learning Paths',
|
||||
name: 'enable_learning_paths',
|
||||
description:
|
||||
'This will enforce students to go through programs assigned to them in the correct order.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
name: 'send_calendar_invite_for_evaluations',
|
||||
description:
|
||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Unsplash Access Key',
|
||||
name: 'unsplash_access_key',
|
||||
description:
|
||||
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -156,9 +178,14 @@ const tabsStructure = computed(() => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
label: 'Lists',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description: 'Manage the members of your learning system',
|
||||
icon: 'UserRoundPlus',
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
description: 'Manage the members of your learning system',
|
||||
@@ -179,26 +206,6 @@ const tabsStructure = computed(() => {
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Copyright',
|
||||
name: 'copyright',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
label: 'Footer "Powered By"',
|
||||
name: 'footer_powered',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Logo',
|
||||
name: 'banner_image',
|
||||
@@ -214,6 +221,23 @@ const tabsStructure = computed(() => {
|
||||
name: 'footer_logo',
|
||||
type: 'Upload',
|
||||
},
|
||||
{
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
rows: 2,
|
||||
},
|
||||
{
|
||||
label: 'Footer "Powered By"',
|
||||
name: 'footer_powered',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
label: 'Copyright',
|
||||
name: 'copyright',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,9 +316,11 @@ const tabsStructure = computed(() => {
|
||||
rows: 10,
|
||||
},
|
||||
{
|
||||
label: 'Ask user category',
|
||||
label: 'Ask for Occupation',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to ask users to select their occupation during the signup process.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
151
frontend/src/components/OnboardingBanner.vue
Normal file
151
frontend/src/components/OnboardingBanner.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div v-if="showOnboardingBanner && onboardingDetails.data">
|
||||
<Tooltip :text="__('Skip Onboarding')" placement="left">
|
||||
<X
|
||||
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
|
||||
@click="skipOnboarding.reload()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="flex items-center justify-evenly bg-gray-100 p-10">
|
||||
<div
|
||||
@click="redirectToCourseForm()"
|
||||
class="flex items-center space-x-2"
|
||||
:class="{
|
||||
'cursor-pointer': !onboardingDetails.data.course_created?.length,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="onboardingDetails.data.course_created?.length"
|
||||
class="py-1 px-1 bg-white rounded-full"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-2 text-green-600" />
|
||||
</span>
|
||||
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
|
||||
1
|
||||
</span>
|
||||
<span class="text-lg font-semibold">
|
||||
{{ __('Create a course') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@click="redirectToChapterForm()"
|
||||
class="flex items-center space-x-2"
|
||||
:class="{
|
||||
'cursor-pointer':
|
||||
onboardingDetails.data.course_created?.length &&
|
||||
!onboardingDetails.data.chapter_created?.length,
|
||||
'text-gray-400': !onboardingDetails.data.course_created?.length,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="onboardingDetails.data.chapter_created?.length"
|
||||
class="py-1 px-1 bg-white rounded-full"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-2 text-green-600" />
|
||||
</span>
|
||||
<span v-else class="font-semibold bg-white px-2 py-1 rounded-full">
|
||||
2
|
||||
</span>
|
||||
<span class="text-lg font-semibold">
|
||||
{{ __('Add a chapter') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@click="redirectToLessonForm()"
|
||||
class="flex items-center space-x-2"
|
||||
:class="{
|
||||
'cursor-pointer':
|
||||
onboardingDetails.data.course_created?.length &&
|
||||
onboardingDetails.data.chapter_created?.length,
|
||||
'text-gray-400':
|
||||
!onboardingDetails.data.course_created?.length ||
|
||||
!onboardingDetails.data.chapter_created?.length,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="onboardingDetails.data.lesson_created?.length"
|
||||
class="py-1 px-1 bg-white rounded-full"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-2 text-green-600" />
|
||||
</span>
|
||||
<span class="font-semibold bg-white px-2 py-1 rounded-full"> 3 </span>
|
||||
<span class="text-lg font-semibold">
|
||||
{{ __('Add a lesson') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Check, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { createResource, Tooltip } from 'frappe-ui'
|
||||
|
||||
const showOnboardingBanner = ref(false)
|
||||
const settings = useSettings()
|
||||
const onboardingDetails = settings.onboardingDetails
|
||||
const router = useRouter()
|
||||
|
||||
watch(onboardingDetails, () => {
|
||||
if (!onboardingDetails.data?.is_onboarded) {
|
||||
showOnboardingBanner.value = true
|
||||
} else {
|
||||
showOnboardingBanner.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const redirectToCourseForm = () => {
|
||||
if (onboardingDetails.data?.course_created.length) {
|
||||
return
|
||||
} else {
|
||||
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
|
||||
}
|
||||
}
|
||||
|
||||
const redirectToChapterForm = () => {
|
||||
if (!onboardingDetails.data?.course_created.length) {
|
||||
return
|
||||
} else {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: onboardingDetails.data?.first_course,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const redirectToLessonForm = () => {
|
||||
if (!onboardingDetails.data?.course_created.length) {
|
||||
return
|
||||
} else if (!onboardingDetails.data?.chapter_created.length) {
|
||||
return
|
||||
} else {
|
||||
router.push({
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: onboardingDetails.data?.first_course,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const skipOnboarding = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Settings',
|
||||
name: 'LMS Settings',
|
||||
fieldname: 'is_onboarding_complete',
|
||||
value: 1,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
onboardingDetails.reload()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,11 +1,27 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
||||
<div class="leading-relaxed">
|
||||
<div
|
||||
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"
|
||||
>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
@@ -22,14 +38,16 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.time" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
'The quiz has a time limit. For each question you will be given {0} seconds.'
|
||||
).format(quiz.data.time)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
|
||||
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
|
||||
<ProgressBar :progress="timerProgress" />
|
||||
<span class="font-semibold">
|
||||
{{ formatTimer(timer) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="activeQuestion == 0">
|
||||
<div class="border text-center p-20 rounded-md">
|
||||
<div class="font-semibold text-lg">
|
||||
@@ -63,19 +81,12 @@
|
||||
class="border rounded-md p-5"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
<span v-if="questionDetails.data.type == 'User Input'">
|
||||
{{ __('Type your answer') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
questionDetails.data.multiple
|
||||
? __('Choose all answers that apply')
|
||||
: __('Choose one answer')
|
||||
}}
|
||||
<span>
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-gray-900 text-sm font-semibold item-left">
|
||||
@@ -139,7 +150,7 @@
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else-if="questionDetails.data.type == 'User Input'">
|
||||
<FormControl
|
||||
v-model="possibleAnswer"
|
||||
type="textarea"
|
||||
@@ -159,8 +170,18 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-5">
|
||||
<div>
|
||||
<div v-else>
|
||||
<TextEditor
|
||||
class="mt-4"
|
||||
:content="possibleAnswer"
|
||||
@change="(val) => (possibleAnswer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
@@ -169,7 +190,11 @@
|
||||
}}
|
||||
</div>
|
||||
<Button
|
||||
v-if="quiz.data.show_answers && !showAnswers.length"
|
||||
v-if="
|
||||
quiz.data.show_answers &&
|
||||
!showAnswers.length &&
|
||||
questionDetails.data.type != 'Open Ended'
|
||||
"
|
||||
@click="checkAnswer()"
|
||||
>
|
||||
<span>
|
||||
@@ -193,11 +218,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="border rounded-md p-20 text-center">
|
||||
<div v-else class="border rounded-md p-20 text-center space-y-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Quiz Summary') }}
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="quizSubmission.data.is_open_ended">
|
||||
{{
|
||||
__(
|
||||
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{
|
||||
__(
|
||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||
@@ -236,20 +268,29 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, Button, createResource, ListView } from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
ListView,
|
||||
TextEditor,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
||||
const user = inject('$user')
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
let questions = reactive([])
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
|
||||
const props = defineProps({
|
||||
quizName: {
|
||||
@@ -270,6 +311,7 @@ const quiz = createResource({
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -285,6 +327,37 @@ const populateQuestions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setupTimer = () => {
|
||||
if (quiz.data.duration) {
|
||||
timer.value = quiz.data.duration * 60
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value--
|
||||
if (timer.value == 0) {
|
||||
clearInterval(timerInterval)
|
||||
submitQuiz()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const formatTimer = (seconds) => {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const secs = (seconds % 60).toString().padStart(2, '0')
|
||||
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
|
||||
}
|
||||
|
||||
const timerProgress = computed(() => {
|
||||
return (timer.value / (quiz.data.duration * 60)) * 100
|
||||
})
|
||||
|
||||
const shuffleArray = (array) => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
@@ -324,6 +397,9 @@ const attempts = createResource({
|
||||
watch(
|
||||
() => quiz.data,
|
||||
() => {
|
||||
if (quiz.data) {
|
||||
populateQuestions()
|
||||
}
|
||||
if (quiz.data && quiz.data.max_attempts) {
|
||||
attempts.reload()
|
||||
resetQuiz()
|
||||
@@ -369,6 +445,7 @@ watch(
|
||||
const startQuiz = () => {
|
||||
activeQuestion.value = 1
|
||||
localStorage.removeItem(quiz.data.title)
|
||||
if (quiz.data.duration) startTimer()
|
||||
}
|
||||
|
||||
const markAnswer = (index) => {
|
||||
@@ -450,9 +527,10 @@ const addToLocalStorage = () => {
|
||||
}
|
||||
|
||||
const nextQuetion = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||
checkAnswer()
|
||||
} else {
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
}
|
||||
@@ -467,7 +545,8 @@ const resetQuestion = () => {
|
||||
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
checkAnswer()
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -477,9 +556,15 @@ const submitQuiz = () => {
|
||||
}
|
||||
|
||||
const createSubmission = () => {
|
||||
quizSubmission.reload().then(() => {
|
||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||
})
|
||||
quizSubmission.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||
if (quiz.data.duration) clearInterval(timerInterval)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const resetQuiz = () => {
|
||||
@@ -488,6 +573,14 @@ const resetQuiz = () => {
|
||||
showAnswers.length = 0
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
}
|
||||
|
||||
const getInstructions = (question) => {
|
||||
if (question.type == 'Choices')
|
||||
if (question.multiple) return __('Choose all answers that apply')
|
||||
else return __('Choose one answer')
|
||||
else return __('Type your answer')
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="flex itemsc-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
<div class="text-xl font-semibold leading-none mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
@@ -29,6 +29,7 @@
|
||||
<script setup>
|
||||
import { Button, Badge } from 'frappe-ui'
|
||||
import SettingFields from '@/components/SettingFields.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
@@ -54,7 +55,14 @@ const update = () => {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit()
|
||||
props.data.save.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<div>
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<Codemirror
|
||||
v-model:value="data[field.name]"
|
||||
:height="200"
|
||||
:options="{
|
||||
mode: field.mode,
|
||||
theme: 'seti',
|
||||
}"
|
||||
/>
|
||||
<CodeEditor
|
||||
:label="__(field.label)"
|
||||
type="HTML"
|
||||
description="The HTML you add here will be shown on your sign up page."
|
||||
v-model="data[field.name]"
|
||||
height="250px"
|
||||
class="shrink-0"
|
||||
:showLineNumbers="true"
|
||||
>
|
||||
</CodeEditor>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'">
|
||||
@@ -53,9 +52,11 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
|
||||
>
|
||||
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all">
|
||||
@@ -73,6 +74,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
size="sm"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
@@ -81,6 +90,7 @@
|
||||
:type="field.type"
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +98,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button } from 'frappe-ui'
|
||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Codemirror from 'codemirror-editor-vue3'
|
||||
import 'codemirror/theme/seti.css'
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<div
|
||||
class="flex items-center w-full duration-300 ease-in-out group"
|
||||
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||
:class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'"
|
||||
>
|
||||
<Tooltip :text="link.label" placement="right">
|
||||
<slot name="icon">
|
||||
@@ -27,9 +27,17 @@
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
"
|
||||
>
|
||||
{{ link.label }}
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||
<span
|
||||
v-if="link.count"
|
||||
class="!ml-auto block text-xs text-gray-600"
|
||||
:class="
|
||||
isCollapsed && link.count > 9
|
||||
? 'absolute top-[2px] right-0 bg-white'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ link.count }}
|
||||
</span>
|
||||
<div
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
<video
|
||||
@timeupdate="updateTime"
|
||||
@ended="videoEnded"
|
||||
class="rounded-lg border border-gray-100"
|
||||
@click="togglePlay"
|
||||
oncontextmenu="return false"
|
||||
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
|
||||
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
@@ -106,6 +108,14 @@ const pauseVideo = () => {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (playing.value) {
|
||||
pauseVideo()
|
||||
} else {
|
||||
playVideo()
|
||||
}
|
||||
}
|
||||
|
||||
const videoEnded = () => {
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
import { createDialog } from '@/utils/dialogs'
|
||||
import translationPlugin from './translation'
|
||||
import { usersStore } from './stores/user'
|
||||
import { sessionStore } from './stores/session'
|
||||
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
app.config.globalProperties.$user = userResource
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
</header>
|
||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
||||
<div class="border-r-2">
|
||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
|
||||
<Tabs
|
||||
v-model="tabIndex"
|
||||
:tabs="tabs"
|
||||
tablistClass="overflow-y-hidden sticky top-11 bg-white z-10"
|
||||
>
|
||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||
<div>
|
||||
<button
|
||||
@@ -236,7 +240,7 @@ const breadcrumbs = computed(() => {
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
user?.data &&
|
||||
batch.data?.students.length &&
|
||||
batch.data?.students?.length &&
|
||||
batch.data?.students.includes(user.data.name)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
|
||||
<div>
|
||||
<FormControl v-model="batch.title" :label="__('Title')" />
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormControl
|
||||
@@ -32,61 +36,73 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
class="mt-4"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ batch.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(batch.image.file_size) }}
|
||||
</span>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="batch.image.file_url" class="border rounded-md w-40" />
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
class="my-4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-600 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@@ -108,12 +124,14 @@
|
||||
:label="__('Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -122,18 +140,22 @@
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,6 +171,7 @@
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
@@ -228,11 +251,11 @@ import {
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, showToast } from '../utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import { showToast } from '../utils'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { capture } from '@/telemetry'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New Batch') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -40,6 +40,7 @@
|
||||
{{ __('Loading Batches...') }}
|
||||
</div>
|
||||
<Tabs
|
||||
v-if="hasBatches"
|
||||
v-model="tabIndex"
|
||||
:tabs="makeTabs"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
@@ -79,24 +80,63 @@
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!batches.loading &&
|
||||
!hasBatches &&
|
||||
(user.data?.is_instructor || user.data?.is_moderator)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Batch') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can link courses and assessments to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!batches.loading && !hasBatches"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No batches found') }}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
@@ -104,13 +144,14 @@ import {
|
||||
Badge,
|
||||
Select,
|
||||
} from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const currentCategory = ref(null)
|
||||
const hasBatches = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -119,10 +160,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const batches = createListResource({
|
||||
const batches = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
url: 'lms.lms.utils.get_batches',
|
||||
cache: ['batches', user?.data?.email],
|
||||
cache: ['batches', user.data?.email],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -183,6 +224,14 @@ const addToTabs = (label) => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(batches, () => {
|
||||
Object.keys(batches.data).forEach((key) => {
|
||||
if (batches.data[key].length) {
|
||||
hasBatches.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -122,9 +122,18 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
||||
<p class="text-gray-600">
|
||||
{{
|
||||
__(
|
||||
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="course.data.avg_rating"
|
||||
v-if="course.data.rating"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.avg_rating }}
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.avg_rating" class="mx-3">·</span>
|
||||
<span v-if="course.data.rating" class="mx-3">·</span>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
@@ -67,14 +67,18 @@
|
||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="course-description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline :courseName="course.data.name" :showOutline="true" />
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.avg_rating"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,7 +120,7 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
@@ -131,26 +135,6 @@ const pageMeta = computed(() => {
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.course-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.course-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.course-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.course-description ul {
|
||||
list-style: disc;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center mt-3 md:mt-0">
|
||||
<Button v-if="courseResource.data?.name" @click="trashCourse()">
|
||||
<template #prefix>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||
<span>
|
||||
{{ __('Save') }}
|
||||
@@ -23,15 +31,23 @@
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@@ -41,49 +57,62 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-gray-700" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{
|
||||
__('Appears on the course card in the course list')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="course.course_image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-gray-600 text-sm">
|
||||
{{ __('Appears on the course card in the course list') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
{{ course.course_image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(course.course_image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
@@ -104,6 +133,8 @@
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Keywords for the course')"
|
||||
class="w-52"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
@@ -121,6 +152,8 @@
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
@@ -130,7 +163,7 @@
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-3"
|
||||
class="flex flex-col space-y-4"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
@@ -223,15 +256,11 @@ import {
|
||||
ref,
|
||||
reactive,
|
||||
watch,
|
||||
getCurrentInstance,
|
||||
} from 'vue'
|
||||
import {
|
||||
convertToTitleCase,
|
||||
showToast,
|
||||
getFileSize,
|
||||
updateDocumentTitle,
|
||||
} from '@/utils'
|
||||
import { showToast, updateDocumentTitle } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { Image, Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
@@ -243,6 +272,8 @@ const newTag = ref('')
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const settingsStore = useSettings()
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -403,6 +434,9 @@ const submitCourse = () => {
|
||||
onSuccess(data) {
|
||||
capture('course_created')
|
||||
showToast('Success', 'Course created successfully', 'check')
|
||||
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
|
||||
settingsStore.onboardingDetails.reload()
|
||||
}
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: data.name },
|
||||
@@ -415,23 +449,37 @@ const submitCourse = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateMandatoryFields = () => {
|
||||
const mandatory_fields = [
|
||||
'title',
|
||||
'short_introduction',
|
||||
'description',
|
||||
'video_link',
|
||||
'course_image',
|
||||
]
|
||||
for (const field of mandatory_fields) {
|
||||
if (!course[field]) {
|
||||
let fieldLabel = convertToTitleCase(field.split('_').join(' '))
|
||||
return `${fieldLabel} is mandatory`
|
||||
const deleteCourse = createResource({
|
||||
url: 'lms.lms.api.delete_course',
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
}
|
||||
if (course.paid_course && (!course.course_price || !course.currency)) {
|
||||
return __('Course price and currency are mandatory for paid courses')
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast(__('Success'), __('Course deleted successfully'), 'check')
|
||||
router.push({ name: 'Courses' })
|
||||
},
|
||||
})
|
||||
|
||||
const trashCourse = () => {
|
||||
$dialog({
|
||||
title: __('Delete Course'),
|
||||
message: __(
|
||||
'Deleting the course will also delete all its chapters and lessons. Are you sure you want to delete this course?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
deleteCourse.submit()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<div class="w-44">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
@@ -17,7 +17,7 @@
|
||||
:placeholder="__('Category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<div class="w-28 md:w-36">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
@@ -30,6 +30,7 @@
|
||||
</FormControl>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
@@ -37,17 +38,18 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button v-if="user.data?.is_moderator" variant="solid">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New Course') }}
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
<div class="">
|
||||
<Tabs
|
||||
v-if="hasCourses"
|
||||
v-model="tabIndex"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
:tabs="makeTabs"
|
||||
@@ -101,47 +103,102 @@
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-4">
|
||||
<div>
|
||||
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-gray-500">
|
||||
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!courses.loading &&
|
||||
(user.data?.is_moderator || user.data?.is_instructor)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Course') }}
|
||||
</div>
|
||||
<span class="text-gray-700 text-sm leading-4">
|
||||
{{ __('You can add chapters and lessons to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!courses.loading && !hasCourses"
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No courses found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Tabs,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
FormControl,
|
||||
call,
|
||||
createResource,
|
||||
FormControl,
|
||||
Tabs,
|
||||
} from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus, Search } from 'lucide-vue-next'
|
||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
const currentCategory = ref(null)
|
||||
const hasCourses = ref(false)
|
||||
const router = useRouter()
|
||||
const settings = useSettings()
|
||||
|
||||
onMounted(() => {
|
||||
checkLearningPath()
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (queries.has('category')) {
|
||||
currentCategory.value = queries.get('category')
|
||||
}
|
||||
})
|
||||
|
||||
const checkLearningPath = () => {
|
||||
if (
|
||||
settings.learningPaths.data &&
|
||||
(!user.data?.is_moderator || !user.data?.is_instructor)
|
||||
) {
|
||||
router.push({ name: 'Programs' })
|
||||
}
|
||||
}
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_courses',
|
||||
cache: ['courses', user.data?.email],
|
||||
@@ -223,6 +280,16 @@ const categories = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(courses, () => {
|
||||
if (courses.data) {
|
||||
Object.keys(courses.data).forEach((section) => {
|
||||
if (courses.data[section].length) {
|
||||
hasCourses.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
v-model="job.job_title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="job.location"
|
||||
:label="__('Location')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl v-model="job.location" :label="__('Location')" />
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
@@ -29,18 +34,21 @@
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="job.status"
|
||||
:label="__('Status')"
|
||||
type="select"
|
||||
:options="jobStatuses"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-gray-600 text-xs mb-1">
|
||||
{{ __('Description') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="job.description"
|
||||
@@ -61,10 +69,12 @@
|
||||
v-model="job.company_name"
|
||||
:label="__('Company Name')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="job.company_website"
|
||||
:label="__('Company Website')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -72,9 +82,11 @@
|
||||
v-model="job.company_email_address"
|
||||
:label="__('Company Email Address')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<label class="block text-gray-600 text-xs mb-1 mt-4">
|
||||
{{ __('Company Logo') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<FileUploader
|
||||
v-if="!job.image"
|
||||
@@ -149,7 +161,7 @@ const newJob = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Job Opportunity',
|
||||
company_logo: job.image.file_url,
|
||||
company_logo: job.image?.file_url,
|
||||
...job,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,46 +52,88 @@
|
||||
</header>
|
||||
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||
<div class="p-4">
|
||||
<div class="flex mb-10">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||
:alt="job.data.company_name"
|
||||
/>
|
||||
<div>
|
||||
<div class="space-y-5 mb-10">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||
:alt="job.data.company_name"
|
||||
/>
|
||||
<div class="text-2xl font-semibold mb-4">
|
||||
{{ job.data.job_title }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-2 md:gap-y-4"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.company_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.location }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
||||
<span>{{ job.data.type }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||
<span class="p-4 bg-green-50 rounded-full">
|
||||
<Building2 class="h-4 w-4 text-green-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Organisation') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.company_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-red-50 rounded-full">
|
||||
<MapPin class="h-4 w-4 text-red-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Location') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-yellow-50 rounded-full">
|
||||
<ClipboardType class="h-4 w-4 text-yellow-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs font-medium text-gray-600 uppercase">
|
||||
{{ __('Category') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="p-4 bg-blue-50 rounded-full">
|
||||
<CalendarDays class="h-4 w-4 text-blue-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Posted on') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="applicationCount.data"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
||||
<span
|
||||
>{{ applicationCount.data }}
|
||||
{{ __('applications received') }}</span
|
||||
>
|
||||
<span class="p-4 bg-purple-50 rounded-full">
|
||||
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
||||
</span>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="text-xs text-gray-600 font-medium uppercase">
|
||||
{{ __('Applications Received') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ applicationCount.data }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,22 @@
|
||||
class="h-7"
|
||||
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
|
||||
/>
|
||||
<div class="flex">
|
||||
<div class="flex space-x-2">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
:placeholder="__('Type')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-28 md:w-36">
|
||||
<FormControl type="text" placeholder="Search" v-model="searchQuery">
|
||||
<template #prefix>
|
||||
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data?.name"
|
||||
:to="{
|
||||
@@ -26,9 +41,9 @@
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="jobs.data?.length">
|
||||
<div v-if="jobsList?.length">
|
||||
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
||||
<div v-for="job in jobs.data">
|
||||
<div v-for="job in jobsList">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'JobDetail',
|
||||
@@ -47,13 +62,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { inject, computed } from 'vue'
|
||||
import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { inject, computed, ref, onMounted } from 'vue'
|
||||
import JobCard from '@/components/JobCard.vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const jobType = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (queries.has('type')) {
|
||||
jobType.value = queries.get('type')
|
||||
}
|
||||
})
|
||||
|
||||
const jobs = createResource({
|
||||
url: 'lms.lms.api.get_job_opportunities',
|
||||
@@ -68,5 +92,32 @@ const pageMeta = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const jobsList = computed(() => {
|
||||
let jobData = jobs.data
|
||||
if (jobType.value && jobType.value != '') {
|
||||
jobData = jobData.filter((job) => job.type == jobType.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
let query = searchQuery.value.toLowerCase()
|
||||
jobData = jobData.filter(
|
||||
(job) =>
|
||||
job.job_title.toLowerCase().includes(query) ||
|
||||
job.company_name.toLowerCase().includes(query) ||
|
||||
job.location.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
return jobData
|
||||
})
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
'',
|
||||
{ label: __('Full Time'), value: 'Full Time' },
|
||||
{ label: __('Part Time'), value: 'Part Time' },
|
||||
{ label: __('Contract'), value: 'Contract' },
|
||||
{ label: __('Freelance'), value: 'Freelance' },
|
||||
]
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -17,14 +17,9 @@
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
<Button v-else @click="redirectToLogin()">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
@@ -108,7 +103,7 @@
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': lesson.data.instructors.length > 1,
|
||||
'avatar-group overlap': lesson.data.instructors?.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -116,10 +111,14 @@
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="lesson.data.instructors" />
|
||||
<CourseInstructors
|
||||
v-if="lesson.data?.instructors"
|
||||
:instructors="lesson.data.instructors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
lesson.data.instructor_content &&
|
||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||
allowInstructorContent()
|
||||
"
|
||||
@@ -150,6 +149,7 @@
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-5"
|
||||
>
|
||||
<LessonContent
|
||||
v-if="lesson.data?.body"
|
||||
:content="lesson.data.body"
|
||||
:youtube="lesson.data.youtube"
|
||||
:quizId="lesson.data.quiz_id"
|
||||
@@ -193,7 +193,7 @@ import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||
@@ -203,6 +203,7 @@ import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const allowDiscussions = ref(false)
|
||||
const editor = ref(null)
|
||||
@@ -242,6 +243,13 @@ const lesson = createResource({
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (Object.keys(data).length === 0) {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
})
|
||||
return
|
||||
}
|
||||
lessonProgress.value = data.membership?.progress
|
||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||
if (
|
||||
@@ -278,7 +286,7 @@ const renderEditor = (holder, content) => {
|
||||
}
|
||||
|
||||
const markProgress = () => {
|
||||
if (user.data && !lesson.data?.progress) {
|
||||
if (user.data && lesson.data && !lesson.data.progress) {
|
||||
progress.submit()
|
||||
}
|
||||
}
|
||||
@@ -300,14 +308,14 @@ const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: lesson?.data?.course_title,
|
||||
route: { name: 'CourseDetail', params: { course: props.courseName } },
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
items.push({
|
||||
label: lesson?.data?.title,
|
||||
route: {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
courseName: props.courseName,
|
||||
chapterNumber: props.chapterNumber,
|
||||
lessonNumber: props.lessonNumber,
|
||||
},
|
||||
@@ -368,16 +376,40 @@ const checkIfDiscussionsAllowed = () => {
|
||||
|
||||
const allowEdit = () => {
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
if (lesson.data?.instructors?.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const allowInstructorContent = () => {
|
||||
if (user.data?.is_moderator) return true
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
if (lesson.data?.instructors?.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const enrollment = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.courseName,
|
||||
member: user.data?.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollStudent = () => {
|
||||
enrollment.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
window.location.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
|
||||
@@ -6,13 +6,22 @@
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="text-ellipsis" :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="saveLesson()" class="mt-3 md:mt-0">
|
||||
<Button
|
||||
variant="solid"
|
||||
@click="saveLesson({ showSuccessMessage: true })"
|
||||
class="mt-3 md:mt-0"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="w-5/6 mx-auto">
|
||||
<FormControl v-model="lesson.title" label="Title" class="mb-4" />
|
||||
<FormControl
|
||||
v-model="lesson.title"
|
||||
label="Title"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="lesson.include_in_preview"
|
||||
type="checkbox"
|
||||
@@ -69,7 +78,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
@@ -83,12 +92,15 @@ import LessonHelp from '@/components/LessonHelp.vue'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const user = inject('$user')
|
||||
const openInstructorEditor = ref(false)
|
||||
const settingsStore = useSettings()
|
||||
let autoSaveInterval
|
||||
let showSuccessMessage = false
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -112,6 +124,7 @@ onMounted(() => {
|
||||
capture('lesson_form_opened')
|
||||
editor.value = renderEditor('content')
|
||||
instructorEditor.value = renderEditor('instructor-notes')
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const renderEditor = (holder) => {
|
||||
@@ -181,12 +194,24 @@ const addInstructorNotes = (data) => {
|
||||
|
||||
const enableAutoSave = () => {
|
||||
autoSaveInterval = setInterval(() => {
|
||||
saveLesson()
|
||||
saveLesson({ showSuccessMessage: false })
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveLesson({ showSuccessMessage: true })
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(autoSaveInterval)
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const newLessonResource = createResource({
|
||||
@@ -338,7 +363,11 @@ const convertToJSON = (lessonData) => {
|
||||
return blocks
|
||||
}
|
||||
|
||||
const saveLesson = () => {
|
||||
const saveLesson = (e) => {
|
||||
showSuccessMessage = false
|
||||
if (typeof e != 'undefined' && e.showSuccessMessage) {
|
||||
showSuccessMessage = true
|
||||
}
|
||||
editor.value.save().then((outputData) => {
|
||||
lesson.content = JSON.stringify(outputData)
|
||||
instructorEditor.value.save().then((outputData) => {
|
||||
@@ -366,6 +395,9 @@ const createNewLesson = () => {
|
||||
onSuccess() {
|
||||
capture('lesson_created')
|
||||
showToast('Success', 'Lesson created successfully', 'check')
|
||||
if (!settingsStore.onboardingDetails.data?.is_onboarded) {
|
||||
settingsStore.onboardingDetails.reload()
|
||||
}
|
||||
lessonDetails.reload()
|
||||
},
|
||||
}
|
||||
@@ -387,6 +419,11 @@ const editCurrentLesson = () => {
|
||||
validate() {
|
||||
return validateLesson()
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessMessage
|
||||
? showToast('Success', 'Lesson updated successfully', 'check')
|
||||
: ''
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="bg-gray-100 rounded-t-md"
|
||||
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
|
||||
367
frontend/src/pages/ProgramForm.vue
Normal file
367
frontend/src/pages/ProgramForm.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button variant="solid" @click="saveProgram()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="program.doc" class="pt-5 px-5 w-3/4 mx-auto space-y-10">
|
||||
<FormControl v-model="program.doc.title" :label="__('Title')" />
|
||||
|
||||
<!-- Courses -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Program Courses') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'course'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="courseColumns"
|
||||
:rows="program.doc.program_courses"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<Draggable
|
||||
:list="program.doc.program_courses"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOrder"
|
||||
class="cursor-move"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<ListRow :row="row" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_courses')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
|
||||
<!-- Members -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Program Members') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'member'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="memberColumns"
|
||||
:rows="program.doc.program_members"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in program.doc.program_members" />
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_members')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
? __('New Program Course')
|
||||
: __('New Program Member'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick: () =>
|
||||
currentForm == 'course'
|
||||
? addProgramCourse(close)
|
||||
: addProgramMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-if="currentForm == 'course'"
|
||||
v-model="course"
|
||||
doctype="LMS Course"
|
||||
:filters="{
|
||||
disable_self_learning: 1,
|
||||
}"
|
||||
:label="__('Program Course')"
|
||||
:description="
|
||||
__(
|
||||
'Only courses for which self learning is disabled can be added to program.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="currentForm == 'member'"
|
||||
v-model="member"
|
||||
doctype="User"
|
||||
:filters="{
|
||||
ignore_user_type: 1,
|
||||
}"
|
||||
:label="__('Program Member')"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { showToast } from '@/utils/'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const showDialog = ref(false)
|
||||
const currentForm = ref(null)
|
||||
const course = ref(null)
|
||||
const member = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
programName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const program = createDocumentResource({
|
||||
doctype: 'LMS Program',
|
||||
name: props.programName,
|
||||
auto: true,
|
||||
cache: ['program', props.programName],
|
||||
})
|
||||
|
||||
const addProgramCourse = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: [
|
||||
...program.doc.program_courses,
|
||||
{ course: course.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
course.value = null
|
||||
showToast(__('Success'), __('Course added to program'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addProgramMember = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_members: [
|
||||
...program.doc.program_members,
|
||||
{ member: member.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
member.value = null
|
||||
showToast(__('Success'), __('Member added to program'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (selections, unselectAll, doctype) => {
|
||||
selections = Array.from(selections)
|
||||
program.setValue.submit(
|
||||
{
|
||||
[doctype]: program.doc[doctype].filter(
|
||||
(row) => !selections.includes(row.name)
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
unselectAll()
|
||||
showToast(__('Success'), __('Items removed successfully'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateOrder = (e) => {
|
||||
let sourceIdx = e.from.dataset.idx
|
||||
let targetIdx = e.to.dataset.idx
|
||||
let courses = program.doc.program_courses
|
||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||
|
||||
courses.forEach((course, index) => {
|
||||
course.idx = index + 1
|
||||
})
|
||||
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: courses,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(__('Success'), __('Course moved successfully'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveProgram = () => {
|
||||
call('frappe.model.rename_doc.update_document_title', {
|
||||
doctype: 'LMS Program',
|
||||
docname: program.doc.name,
|
||||
name: program.doc.title,
|
||||
}).then((data) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: data } })
|
||||
})
|
||||
}
|
||||
|
||||
const courseColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'course_title',
|
||||
width: 3,
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
key: 'course',
|
||||
width: 3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const memberColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Progress (%)',
|
||||
key: 'progress',
|
||||
width: 3,
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadbrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Programs',
|
||||
route: { name: 'Programs' },
|
||||
},
|
||||
{
|
||||
label: props.programName === 'new' ? 'New Program' : props.programName,
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
215
frontend/src/pages/Programs.vue
Normal file
215
frontend/src/pages/Programs.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
@click="showDialog = true"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="programs.data?.length" class="pt-5 px-5">
|
||||
<div v-for="program in programs.data" class="mb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Badge
|
||||
v-if="program.members"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.members }}
|
||||
{{
|
||||
program.members == 1 ? __(singularize('members')) : __('members')
|
||||
}}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="program.progress"
|
||||
variant="subtle"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.progress }}{{ __('% completed') }}
|
||||
</Badge>
|
||||
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
:to="{
|
||||
name: 'ProgramForm',
|
||||
params: { programName: program.name },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Edit class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="program.courses?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div v-for="course in program.courses" class="relative group">
|
||||
<CourseCard
|
||||
:course="course"
|
||||
@click="enrollMember(program.name, course.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 bg-black-overlay-500 opacity-60 rounded-md"
|
||||
></div>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<LockKeyhole class="size-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600 mt-4">
|
||||
{{ __('No courses in this program') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No programs found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title: __('New Program'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => createProgram(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl :label="__('Title')" v-model="title" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, singularize } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject('$user')
|
||||
const showDialog = ref(false)
|
||||
const router = useRouter()
|
||||
const title = ref('')
|
||||
const settings = useSettings()
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
!settings.learningPaths.data &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: true,
|
||||
cache: 'programs',
|
||||
})
|
||||
|
||||
const createProgram = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Program',
|
||||
title: title.value,
|
||||
},
|
||||
}).then((res) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: res.name } })
|
||||
})
|
||||
}
|
||||
|
||||
const enrollMember = (program, course) => {
|
||||
call('lms.lms.utils.enroll_in_program_course', {
|
||||
program: program,
|
||||
course: course,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.current_lesson) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: data.current_lesson.split('-')[0],
|
||||
lessonNumber: data.current_lesson.split('-')[1],
|
||||
},
|
||||
})
|
||||
} else if (data) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
})
|
||||
}
|
||||
|
||||
const lockCourse = (course) => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return false
|
||||
if (course.membership) return false
|
||||
if (course.eligible) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const breadbrumbs = computed(() => [
|
||||
{
|
||||
label: 'Programs',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -3,14 +3,42 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
<div class="space-x-2">
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
:to="{
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Open') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
:to="{
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
@@ -20,13 +48,20 @@
|
||||
? __('Title')
|
||||
: __('Enter a title and save the quiz to proceed')
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div v-if="quizDetails.data?.name">
|
||||
<div class="grid grid-cols-3 gap-5 mt-4 mb-8">
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.max_attempts"
|
||||
:label="__('Maximun Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.total_marks"
|
||||
:label="__('Total Marks')"
|
||||
@@ -40,7 +75,7 @@
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
@@ -58,7 +93,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="text-sm font-semibold mb-4">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Shuffle Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
@@ -78,7 +113,7 @@
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-sm font-semibold">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button @click="openQuestionModal()">
|
||||
@@ -107,6 +142,7 @@
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in quiz.questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
@@ -170,7 +206,6 @@ import {
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
isReactive,
|
||||
} from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
@@ -198,6 +233,7 @@ const quiz = reactive({
|
||||
total_marks: 0,
|
||||
passing_percentage: 0,
|
||||
max_attempts: 0,
|
||||
duration: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
@@ -347,17 +383,17 @@ const questionColumns = computed(() => {
|
||||
{
|
||||
label: __('ID'),
|
||||
key: 'question',
|
||||
width: '25%',
|
||||
width: '10rem',
|
||||
},
|
||||
{
|
||||
label: __('Question'),
|
||||
key: __('question_detail'),
|
||||
width: '60%',
|
||||
width: '40rem',
|
||||
},
|
||||
{
|
||||
label: __('Marks'),
|
||||
key: 'marks',
|
||||
width: '10%',
|
||||
width: '5rem',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
58
frontend/src/pages/QuizPage.vue
Normal file
58
frontend/src/pages/QuizPage.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
|
||||
<Quiz :quizName="quizID" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const title = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Quiz',
|
||||
fieldname: 'title',
|
||||
filters: {
|
||||
name: props.quizID,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: title.data?.title,
|
||||
description: __('Quiz Submission'),
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
@@ -2,47 +2,145 @@
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="submisisonDetails.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" @click="saveSubmission()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-1/2 mx-auto py-10">
|
||||
<Quiz :quizName="quizID" />
|
||||
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-5">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ submisisonDetails.doc.member_name }}
|
||||
</div>
|
||||
<div class="space-y-4 border p-5 rounded-md">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.quiz_title"
|
||||
:label="__('Quiz')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.member_name"
|
||||
:label="__('Member')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.score"
|
||||
:label="__('Score')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.percentage"
|
||||
:label="__('Percentage')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="row in submisisonDetails.doc.result"
|
||||
class="border p-5 rounded-md space-y-4"
|
||||
>
|
||||
<div class="flex space-x-1 font-semibold">
|
||||
<span class="leading-5" v-html="row.question"> </span>
|
||||
</div>
|
||||
<div v-html="row.answer" class="leading-5"></div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl v-model="row.marks" :label="__('Marks')" />
|
||||
<FormControl
|
||||
v-model="row.marks_out_of"
|
||||
:label="__('Marks out of')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import {
|
||||
createDocumentResource,
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
Button,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveSubmission()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
submission: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const title = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Quiz',
|
||||
fieldname: 'title',
|
||||
filters: {
|
||||
name: props.quizID,
|
||||
},
|
||||
},
|
||||
const submisisonDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz Submission',
|
||||
name: props.submission,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||
return [
|
||||
{
|
||||
label: __('Quiz Submissions'),
|
||||
route: {
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: submisisonDetails.doc.quiz,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: submisisonDetails.doc.quiz_title,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const saveSubmission = () => {
|
||||
submisisonDetails.save.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<ListView
|
||||
:columns="quizColumns"
|
||||
:rows="submissions.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<router-link
|
||||
v-for="row in submissions.data"
|
||||
:to="{
|
||||
name: 'QuizSubmission',
|
||||
params: {
|
||||
submission: row.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
Breadcrumbs,
|
||||
ListView,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||
router.push({ name: 'Courses' })
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const submissions = createListResource({
|
||||
doctype: 'LMS Quiz Submission',
|
||||
filters: {
|
||||
quiz: props.quizID,
|
||||
},
|
||||
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: __('Quiz'),
|
||||
key: 'quiz_title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: __('Score'),
|
||||
key: 'score',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: __('Percentage'),
|
||||
key: 'percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submissions') }]
|
||||
})
|
||||
</script>
|
||||
@@ -19,7 +19,7 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
|
||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<ListView
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
@@ -47,6 +47,22 @@
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No quizzes found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'You have not created any quizzes yet. To create a new quiz, click on the "New Quiz" button above.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -61,7 +77,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
204
frontend/src/pages/SCORMChapter.vue
Normal file
204
frontend/src/pages/SCORMChapter.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div
|
||||
v-if="
|
||||
readyToRender &&
|
||||
(enrollment.data?.length ||
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor)
|
||||
"
|
||||
>
|
||||
<iframe :src="chapter.doc.launch_file" class="w-full h-screen" />
|
||||
</div>
|
||||
<div v-else-if="!enrollment.data?.length">
|
||||
<div class="text-center pt-10 px-5 md:px-0 pb-10">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
{{
|
||||
__(
|
||||
'You are not enrolled in this course. Please enroll to access this lesson.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<Button variant="solid" @click="enrollStudent()">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
createListResource,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onBeforeMount, ref } from 'vue'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const sidebarStore = useSidebar()
|
||||
const user = inject('$user')
|
||||
const readyToRender = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
chapterName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
sidebarStore.isSidebarCollapsed = true
|
||||
window.API_1484_11 = {
|
||||
Initialize: () => 'true',
|
||||
Terminate: () => 'true',
|
||||
GetValue: (key) => {
|
||||
console.log(`GET: ${key}`)
|
||||
return getDataFromLMS(key)
|
||||
},
|
||||
SetValue: (key, value) => {
|
||||
console.log(`SET: ${key} to value: ${value}`)
|
||||
|
||||
saveDataToLMS(key, value)
|
||||
return 'true'
|
||||
},
|
||||
Commit: () => 'true',
|
||||
GetLastError: () => '0',
|
||||
GetErrorString: () => '',
|
||||
GetDiagnostic: () => '',
|
||||
}
|
||||
window.API = {
|
||||
LMSInitialize: () => 'true',
|
||||
LMSFinish: () => 'true',
|
||||
LMSGetValue: (key) => {
|
||||
console.log(`GET: ${key}`)
|
||||
return getDataFromLMS(key)
|
||||
},
|
||||
LMSSetValue: (key, value) => {
|
||||
console.log(`SET: ${key} to value: ${value}`)
|
||||
saveDataToLMS(key, value)
|
||||
return 'true'
|
||||
},
|
||||
LMSCommit: () => 'true',
|
||||
LMSGetLastError: () => '0',
|
||||
LMSGetErrorString: () => '',
|
||||
LMSGetDiagnostic: () => '',
|
||||
}
|
||||
})
|
||||
|
||||
const getDataFromLMS = (key) => {
|
||||
if (key == 'cmi.core.lesson_status') {
|
||||
if (progress.data?.status == 'Complete') {
|
||||
return 'passed'
|
||||
}
|
||||
return 'incomplete'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const saveDataToLMS = (key, value) => {
|
||||
if (key == 'cmi.core.lesson_status' && value == 'passed') {
|
||||
saveProgress()
|
||||
}
|
||||
}
|
||||
|
||||
const enrollment = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
fields: ['member', 'course'],
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
member: user.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
cache: ['enrollments', props.courseName, user.data?.name],
|
||||
})
|
||||
|
||||
const chapter = createDocumentResource({
|
||||
doctype: 'Course Chapter',
|
||||
name: props.chapterName,
|
||||
auto: true,
|
||||
cache: ['chapter', props.chapterName],
|
||||
onSuccess(data) {
|
||||
progress.submit()
|
||||
},
|
||||
})
|
||||
|
||||
const saveProgress = () => {
|
||||
call('lms.lms.doctype.course_lesson.course_lesson.save_progress', {
|
||||
lesson: chapter.doc.lessons[0].lesson,
|
||||
course: props.courseName,
|
||||
})
|
||||
}
|
||||
|
||||
const progress = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course Progress',
|
||||
fieldname: 'status',
|
||||
filters: {
|
||||
member: user.data?.name,
|
||||
lesson: chapter.doc.lessons[0].lesson,
|
||||
chapter: chapter.doc.name,
|
||||
course: chapter.doc?.course,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
readyToRender.value = true
|
||||
},
|
||||
})
|
||||
|
||||
const enrollStudent = () => {
|
||||
enrollment.insert.submit(
|
||||
{
|
||||
course: props.courseName,
|
||||
member: user.data?.name,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
window.location.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Courses',
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
{
|
||||
label: chapter.doc?.course_title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
},
|
||||
{
|
||||
label: chapter.doc?.title,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: chapter?.doc?.title,
|
||||
description: __('This is a chapter in the course {0}').format(
|
||||
chapter?.doc?.course_title
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
@@ -27,6 +27,12 @@ const routes = [
|
||||
component: () => import('@/pages/Lesson.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/learn/:chapterName',
|
||||
name: 'SCORMChapter',
|
||||
component: () => import('@/pages/SCORMChapter.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/batches',
|
||||
name: 'Batches',
|
||||
@@ -160,10 +166,33 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/quiz/:quizID',
|
||||
name: 'Quiz',
|
||||
name: 'QuizPage',
|
||||
component: () => import('@/pages/QuizPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quiz-submissions/:quizID',
|
||||
name: 'QuizSubmissionList',
|
||||
component: () => import('@/pages/QuizSubmissionList.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quiz-submission/:submission',
|
||||
name: 'QuizSubmission',
|
||||
component: () => import('@/pages/QuizSubmission.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/programs/:programName',
|
||||
name: 'ProgramForm',
|
||||
component: () => import('@/pages/ProgramForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/programs',
|
||||
name: 'Programs',
|
||||
component: () => import('@/pages/Programs.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const isSettingsOpen = ref(false)
|
||||
const activeTab = ref(null)
|
||||
const learningPaths = createResource({
|
||||
url: 'frappe.client.get_single_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Settings',
|
||||
field: 'enable_learning_paths',
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['learningPaths'],
|
||||
})
|
||||
|
||||
const onboardingDetails = createResource({
|
||||
url: 'lms.lms.utils.is_onboarding_complete',
|
||||
auto: true,
|
||||
cache: ['onboardingDetails'],
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
learningPaths,
|
||||
onboardingDetails,
|
||||
}
|
||||
})
|
||||
|
||||
10
frontend/src/stores/sidebar.js
Normal file
10
frontend/src/stores/sidebar.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSidebar = defineStore('sidebar', () => {
|
||||
const isSidebarCollapsed = ref(false)
|
||||
|
||||
return {
|
||||
isSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import updateLocale from 'dayjs/esm/plugin/updateLocale'
|
||||
import isToday from 'dayjs/esm/plugin/isToday'
|
||||
import isSameOrBefore from 'dayjs/esm/plugin/isSameOrBefore'
|
||||
import isSameOrAfter from 'dayjs/esm/plugin/isSameOrAfter'
|
||||
import utc from 'dayjs/esm/plugin/utc'
|
||||
import timezone from 'dayjs/esm/plugin/timezone'
|
||||
|
||||
dayjs.extend(updateLocale)
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -12,5 +14,7 @@ dayjs.extend(localizedFormat)
|
||||
dayjs.extend(isToday)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export default dayjs
|
||||
|
||||
@@ -11,6 +11,7 @@ import { watch } from 'vue'
|
||||
import dayjs from '@/utils/dayjs'
|
||||
import Embed from '@editorjs/embed'
|
||||
import SimpleImage from '@editorjs/simple-image'
|
||||
import Table from '@editorjs/table'
|
||||
|
||||
export function createToast(options) {
|
||||
toast({
|
||||
@@ -57,6 +58,15 @@ export function formatNumberIntoCurrency(number, currency) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// create a function that formats numbers in thousands to k
|
||||
|
||||
export function formatAmount(amount) {
|
||||
if (amount > 999) {
|
||||
return (amount / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
export function convertToTitleCase(str) {
|
||||
if (!str) {
|
||||
return ''
|
||||
@@ -82,10 +92,13 @@ export function getFileSize(file_size) {
|
||||
|
||||
export function showToast(title, text, icon, iconClasses = null) {
|
||||
if (!iconClasses) {
|
||||
iconClasses =
|
||||
icon == 'check'
|
||||
? 'bg-green-600 text-white rounded-md p-px'
|
||||
: 'bg-red-600 text-white rounded-md p-px'
|
||||
if (icon == 'check') {
|
||||
iconClasses = 'bg-green-600 text-white rounded-md p-px'
|
||||
} else if (icon == 'alert-circle') {
|
||||
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
|
||||
} else {
|
||||
iconClasses = 'bg-red-600 text-white rounded-md p-px'
|
||||
}
|
||||
}
|
||||
createToast({
|
||||
title: title,
|
||||
@@ -138,6 +151,7 @@ export function getEditorTools() {
|
||||
quiz: Quiz,
|
||||
upload: Upload,
|
||||
image: SimpleImage,
|
||||
table: Table,
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
|
||||
@@ -51,7 +51,7 @@ export class Quiz {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
}
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
|
||||
<span class="font-medium">
|
||||
Quiz: ${quiz}
|
||||
</span>
|
||||
@@ -60,6 +60,9 @@ export class Quiz {
|
||||
}
|
||||
|
||||
renderQuizModal() {
|
||||
if (this.readOnly) {
|
||||
return
|
||||
}
|
||||
const app = createApp(QuizPlugin, {
|
||||
onQuizAddition: (quiz) => {
|
||||
this.data.quiz = quiz
|
||||
|
||||
@@ -56,9 +56,11 @@ export class Upload {
|
||||
app.mount(this.wrapper)
|
||||
return
|
||||
} else if (file.file_type == 'PDF') {
|
||||
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
||||
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${
|
||||
window.location.origin
|
||||
}${encodeURI(
|
||||
file.file_url
|
||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>`
|
||||
return
|
||||
} else {
|
||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||
|
||||
@@ -120,6 +120,13 @@
|
||||
dependencies:
|
||||
"@codexteam/icons" "^0.0.6"
|
||||
|
||||
"@editorjs/table@^2.4.2":
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@editorjs/table/-/table-2.4.2.tgz#99a2b3f9ea8f39c9ca4df80b8e63bff6e21d0193"
|
||||
integrity sha512-zGmwLCarsaTgOfccxR3Lc6oC3QTX0JdoK0O3+8TE/VCR/xnW92VO7rAcu4cqTwtbFMQErYl8id9a5hM23vyFng==
|
||||
dependencies:
|
||||
"@codexteam/icons" "^0.0.6"
|
||||
|
||||
"@esbuild/aix-ppc64@0.21.5":
|
||||
version "0.21.5"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
|
||||
@@ -852,6 +859,11 @@
|
||||
dependencies:
|
||||
vue-demi ">=0.14.8"
|
||||
|
||||
ace-builds@^1.36.2:
|
||||
version "1.36.2"
|
||||
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.2.tgz#9499bd59e839a335ac4850e74549ca8d849dc554"
|
||||
integrity sha512-eqqfbGwx/GKjM/EnFu4QtQ+d2NNBu84MGgxoG8R5iyFpcVeQ4p9YlTL+ZzdEJqhdkASqoqOxCSNNGyB6lvMm+A==
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
@@ -1219,10 +1231,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.69:
|
||||
version "0.1.69"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.69.tgz#bfc6d19dff97d2666c36da63f5de62f819539406"
|
||||
integrity sha512-MKHYTcRvmccZwTYlIcmf4OCbJQH5eqKXsq3Cj2lbnmoWuuTh9m7T3AoRKEwOIlZ0mSGCH9yzaF2BINBXGpIJdQ==
|
||||
frappe-ui@^0.1.72:
|
||||
version "0.1.72"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.72.tgz#f5550056ddee7ad4341f2c1825d046404d221820"
|
||||
integrity sha512-XWYKmCjw3ViD+/+tZMUiYqwHFlMGMsVuazOYiN5bKlE+aiheJsnHlOOUyQswYX1Y7jNxuC7gGpSLNg2ZpXA7hA==
|
||||
dependencies:
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
"@popperjs/core" "^2.11.2"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.0"
|
||||
__version__ = "2.15.0"
|
||||
|
||||
@@ -110,7 +110,8 @@ doc_events = {
|
||||
# ---------------
|
||||
scheduler_events = {
|
||||
"hourly": [
|
||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
|
||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
||||
"lms.lms.api.update_course_statistics",
|
||||
],
|
||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||
}
|
||||
@@ -185,6 +186,7 @@ jinja = {
|
||||
"lms.lms.utils.get_lesson_url",
|
||||
"lms.page_renderers.get_profile_url",
|
||||
"lms.overrides.user.get_palette",
|
||||
"lms.lms.utils.is_instructor",
|
||||
],
|
||||
"filters": [],
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import frappe
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
||||
from lms.lms.api import give_dicussions_permission
|
||||
|
||||
|
||||
def after_install():
|
||||
add_pages_to_nav()
|
||||
create_batch_source()
|
||||
give_dicussions_permission()
|
||||
|
||||
|
||||
def after_sync():
|
||||
@@ -64,7 +66,9 @@ def delete_lms_roles():
|
||||
|
||||
|
||||
def create_course_creator_role():
|
||||
if not frappe.db.exists("Role", "Course Creator"):
|
||||
if frappe.db.exists("Role", "Course Creator"):
|
||||
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
|
||||
else:
|
||||
role = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Role",
|
||||
@@ -77,7 +81,9 @@ def create_course_creator_role():
|
||||
|
||||
|
||||
def create_moderator_role():
|
||||
if not frappe.db.exists("Role", "Moderator"):
|
||||
if frappe.db.exists("Role", "Moderator"):
|
||||
frappe.db.set_value("Role", "Moderator", "desk_access", 0)
|
||||
else:
|
||||
role = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Role",
|
||||
@@ -90,7 +96,9 @@ def create_moderator_role():
|
||||
|
||||
|
||||
def create_evaluator_role():
|
||||
if not frappe.db.exists("Role", "Batch Evaluator"):
|
||||
if frappe.db.exists("Role", "Batch Evaluator"):
|
||||
frappe.db.set_value("Role", "Batch Evaluator", "desk_access", 0)
|
||||
else:
|
||||
role = frappe.new_doc("Role")
|
||||
role.update(
|
||||
{
|
||||
@@ -103,7 +111,9 @@ def create_evaluator_role():
|
||||
|
||||
|
||||
def create_lms_student_role():
|
||||
if not frappe.db.exists("Role", "LMS Student"):
|
||||
if frappe.db.exists("Role", "LMS Student"):
|
||||
frappe.db.set_value("Role", "LMS Student", "desk_access", 0)
|
||||
else:
|
||||
role = frappe.new_doc("Role")
|
||||
role.update(
|
||||
{
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSJobApplication(FrappeTestCase):
|
||||
class TestLMSJobApplication(UnitTestCase):
|
||||
pass
|
||||
|
||||
258
lms/lms/api.py
258
lms/lms/api.py
@@ -1,13 +1,20 @@
|
||||
"""API methods for the LMS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import frappe
|
||||
import zipfile
|
||||
import os
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime
|
||||
from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
||||
from typing import Optional
|
||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||
from xml.dom.minidom import parseString
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -293,7 +300,11 @@ def get_branding():
|
||||
image_fields = ["banner_image", "footer_logo", "favicon"]
|
||||
|
||||
for field in image_fields:
|
||||
website_settings.update({field: get_file_info(website_settings.get(field))})
|
||||
if website_settings.get(field):
|
||||
file_info = get_file_info(website_settings.get(field))
|
||||
website_settings.update({field: json.loads(json.dumps(file_info))})
|
||||
else:
|
||||
website_settings.update({field: None})
|
||||
|
||||
return website_settings
|
||||
|
||||
@@ -322,7 +333,7 @@ def get_evaluator_details(evaluator):
|
||||
)
|
||||
|
||||
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
|
||||
doc = frappe.get_doc("Course Evaluator", evaluator)
|
||||
else:
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = evaluator
|
||||
@@ -486,7 +497,15 @@ def delete_sidebar_item(webpage):
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_lesson(lesson, chapter):
|
||||
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
|
||||
# Delete Reference
|
||||
chapter = frappe.get_doc("Course Chapter", chapter)
|
||||
chapter.lessons = [row for row in chapter.lessons if row.lesson != lesson]
|
||||
chapter.save()
|
||||
|
||||
# Delete progress
|
||||
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
|
||||
|
||||
# Delete Lesson
|
||||
frappe.db.delete("Course Lesson", lesson)
|
||||
|
||||
|
||||
@@ -576,14 +595,17 @@ def get_members(start=0, search=""):
|
||||
"""
|
||||
|
||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||
or_filters = {}
|
||||
|
||||
if search:
|
||||
filters["full_name"] = ["like", f"%{search}%"]
|
||||
or_filters["full_name"] = ["like", f"%{search}%"]
|
||||
or_filters["email"] = ["like", f"%{search}%"]
|
||||
|
||||
members = frappe.get_all(
|
||||
"User",
|
||||
filters=filters,
|
||||
fields=["name", "full_name", "user_image", "username", "last_active"],
|
||||
or_filters=or_filters,
|
||||
page_length=20,
|
||||
start=start,
|
||||
)
|
||||
@@ -754,3 +776,229 @@ def get_payment_gateway_details(payment_gateway):
|
||||
"doctype": doctype,
|
||||
"docname": docname,
|
||||
}
|
||||
|
||||
|
||||
def update_course_statistics():
|
||||
courses = frappe.get_all("LMS Course", fields=["name"])
|
||||
|
||||
for course in courses:
|
||||
lessons = get_lesson_count(course.name)
|
||||
|
||||
enrollments = frappe.db.count(
|
||||
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
|
||||
)
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Course",
|
||||
course.name,
|
||||
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_announcements(batch):
|
||||
return frappe.get_all(
|
||||
"Communication",
|
||||
filters={
|
||||
"reference_doctype": "LMS Batch",
|
||||
"reference_name": batch,
|
||||
},
|
||||
fields=[
|
||||
"subject",
|
||||
"content",
|
||||
"recipients",
|
||||
"cc",
|
||||
"communication_date",
|
||||
"sender",
|
||||
"sender_full_name",
|
||||
],
|
||||
order_by="communication_date desc",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_course(course):
|
||||
|
||||
chapters = frappe.get_all("Course Chapter", {"course": course}, pluck="name")
|
||||
|
||||
chapter_references = frappe.get_all(
|
||||
"Chapter Reference", {"parent": course}, pluck="name"
|
||||
)
|
||||
|
||||
for chapter in chapters:
|
||||
lessons = frappe.get_all("Course Lesson", {"chapter": chapter}, pluck="name")
|
||||
|
||||
lesson_references = frappe.get_all(
|
||||
"Lesson Reference", {"parent": chapter}, pluck="name"
|
||||
)
|
||||
|
||||
for lesson in lesson_references:
|
||||
frappe.delete_doc("Lesson Reference", lesson)
|
||||
|
||||
for lesson in lessons:
|
||||
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
|
||||
|
||||
topics = frappe.get_all(
|
||||
"Discussion Topic",
|
||||
{"reference_doctype": "Course Lesson", "reference_docname": lesson},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
for topic in topics:
|
||||
frappe.db.delete("Discussion Reply", {"topic": topic})
|
||||
|
||||
frappe.db.delete("Discussion Topic", topic)
|
||||
|
||||
frappe.delete_doc("Course Lesson", lesson)
|
||||
|
||||
for chapter in chapter_references:
|
||||
frappe.delete_doc("Chapter Reference", chapter)
|
||||
|
||||
for chapter in chapters:
|
||||
frappe.delete_doc("Course Chapter", chapter)
|
||||
|
||||
frappe.db.delete("LMS Enrollment", {"course": course})
|
||||
frappe.delete_doc("LMS Course", course)
|
||||
|
||||
|
||||
def give_dicussions_permission():
|
||||
doctypes = ["Discussion Topic", "Discussion Reply"]
|
||||
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
|
||||
for doctype in doctypes:
|
||||
for role in roles:
|
||||
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role}):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Custom DocPerm",
|
||||
"parent": doctype,
|
||||
"role": role,
|
||||
"read": 1,
|
||||
"write": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
}
|
||||
).save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
|
||||
values = frappe._dict(
|
||||
{"title": title, "course": course, "is_scorm_package": is_scorm_package}
|
||||
)
|
||||
|
||||
if is_scorm_package:
|
||||
scorm_package = frappe._dict(scorm_package)
|
||||
extract_path = extract_package(course, title, scorm_package)
|
||||
|
||||
values.update(
|
||||
{
|
||||
"scorm_package": scorm_package.name,
|
||||
"scorm_package_path": extract_path.split("public")[1],
|
||||
"manifest_file": get_manifest_file(extract_path).split("public")[1],
|
||||
"launch_file": get_launch_file(extract_path).split("public")[1],
|
||||
}
|
||||
)
|
||||
|
||||
if name:
|
||||
chapter = frappe.get_doc("Course Chapter", name)
|
||||
else:
|
||||
chapter = frappe.new_doc("Course Chapter")
|
||||
|
||||
chapter.update(values)
|
||||
chapter.save()
|
||||
|
||||
if is_scorm_package and not len(chapter.lessons):
|
||||
add_lesson(title, chapter.name, course)
|
||||
|
||||
return chapter
|
||||
|
||||
|
||||
def extract_package(course, title, scorm_package):
|
||||
package = frappe.get_doc("File", scorm_package.name)
|
||||
zip_path = package.get_full_path()
|
||||
|
||||
extract_path = frappe.get_site_path("public", "files", "scorm", course, title)
|
||||
zipfile.ZipFile(zip_path).extractall(extract_path)
|
||||
return extract_path
|
||||
|
||||
|
||||
def get_manifest_file(extract_path):
|
||||
manifest_file = None
|
||||
for root, dirs, files in os.walk(extract_path):
|
||||
for file in files:
|
||||
if file == "imsmanifest.xml":
|
||||
manifest_file = os.path.join(root, file)
|
||||
break
|
||||
if manifest_file:
|
||||
break
|
||||
return manifest_file
|
||||
|
||||
|
||||
def get_launch_file(extract_path):
|
||||
launch_file = None
|
||||
manifest_file = get_manifest_file(extract_path)
|
||||
|
||||
if manifest_file:
|
||||
with open(manifest_file) as file:
|
||||
data = file.read()
|
||||
dom = parseString(data)
|
||||
resource = dom.getElementsByTagName("resource")
|
||||
for res in resource:
|
||||
if (
|
||||
res.getAttribute("adlcp:scormtype") == "sco"
|
||||
or res.getAttribute("adlcp:scormType") == "sco"
|
||||
):
|
||||
launch_file = res.getAttribute("href")
|
||||
break
|
||||
|
||||
if launch_file:
|
||||
launch_file = os.path.join(os.path.dirname(manifest_file), launch_file)
|
||||
|
||||
return launch_file
|
||||
|
||||
|
||||
def add_lesson(title, chapter, course):
|
||||
lesson = frappe.new_doc("Course Lesson")
|
||||
lesson.update(
|
||||
{
|
||||
"title": title,
|
||||
"chapter": chapter,
|
||||
"course": course,
|
||||
}
|
||||
)
|
||||
lesson.insert()
|
||||
|
||||
lesson_reference = frappe.new_doc("Lesson Reference")
|
||||
lesson_reference.update(
|
||||
{
|
||||
"lesson": lesson.name,
|
||||
"parent": chapter,
|
||||
"parenttype": "Course Chapter",
|
||||
"parentfield": "lessons",
|
||||
}
|
||||
)
|
||||
lesson_reference.insert()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_chapter(chapter):
|
||||
chapterInfo = frappe.db.get_value(
|
||||
"Course Chapter", chapter, ["is_scorm_package", "scorm_package_path"], as_dict=True
|
||||
)
|
||||
|
||||
if chapterInfo.is_scorm_package:
|
||||
delete_scorm_package(chapterInfo.scorm_package_path)
|
||||
|
||||
frappe.db.delete("Chapter Reference", {"chapter": chapter})
|
||||
frappe.db.delete("Lesson Reference", {"parent": chapter})
|
||||
frappe.db.delete("Course Lesson", {"chapter": chapter})
|
||||
frappe.db.delete("Course Chapter", chapter)
|
||||
|
||||
|
||||
def delete_scorm_package(scorm_package_path):
|
||||
scorm_package_path = frappe.get_site_path("public", scorm_package_path)
|
||||
if os.path.exists(scorm_package_path):
|
||||
shutil.rmtree(scorm_package_path)
|
||||
|
||||
@@ -7,17 +7,3 @@ from frappe.model.document import Document
|
||||
|
||||
class BatchStudent(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def enroll_batch(batch_name):
|
||||
if frappe.db.exists(
|
||||
"Batch Student", {"student": frappe.session.user, "parent": batch_name}
|
||||
):
|
||||
frappe.throw("You are already enrolled in this batch")
|
||||
enrollment = frappe.new_doc("Batch Student")
|
||||
enrollment.student = frappe.session.user
|
||||
enrollment.parent = batch_name
|
||||
enrollment.parentfield = "students"
|
||||
enrollment.parenttype = "LMS Batch"
|
||||
enrollment.save(ignore_permissions=True)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestBatchStudent(FrappeTestCase):
|
||||
class TestBatchStudent(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -8,10 +8,17 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"course",
|
||||
"title",
|
||||
"column_break_3",
|
||||
"description",
|
||||
"course",
|
||||
"course_title",
|
||||
"scorm_section",
|
||||
"is_scorm_package",
|
||||
"scorm_package",
|
||||
"scorm_package_path",
|
||||
"column_break_dlnw",
|
||||
"manifest_file",
|
||||
"launch_file",
|
||||
"section_break_5",
|
||||
"lessons"
|
||||
],
|
||||
@@ -35,11 +42,6 @@
|
||||
"fieldname": "column_break_3",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_5",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -49,6 +51,56 @@
|
||||
"fieldtype": "Table",
|
||||
"label": "Lessons",
|
||||
"options": "Lesson Reference"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_scorm_package",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is SCORM Package"
|
||||
},
|
||||
{
|
||||
"depends_on": "is_scorm_package",
|
||||
"fieldname": "manifest_file",
|
||||
"fieldtype": "Code",
|
||||
"label": "Manifest File",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "is_scorm_package",
|
||||
"fieldname": "launch_file",
|
||||
"fieldtype": "Code",
|
||||
"label": "Launch File",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scorm_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "SCORM"
|
||||
},
|
||||
{
|
||||
"fieldname": "scorm_package",
|
||||
"fieldtype": "Link",
|
||||
"label": "SCORM Package",
|
||||
"options": "File",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dlnw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "is_scorm_package",
|
||||
"fieldname": "scorm_package_path",
|
||||
"fieldtype": "Code",
|
||||
"label": "SCORM Package Path",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "course.title",
|
||||
"fieldname": "course_title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Course Title",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -59,7 +111,7 @@
|
||||
"link_fieldname": "chapter"
|
||||
}
|
||||
],
|
||||
"modified": "2023-09-29 17:03:58.013819",
|
||||
"modified": "2024-11-15 12:03:31.370943",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Chapter",
|
||||
@@ -79,17 +131,14 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"search_fields": "title",
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
# Copyright (c) 2021, FOSS United and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.telemetry import capture
|
||||
from lms.lms.utils import get_course_progress
|
||||
from lms.lms.api import update_course_statistics
|
||||
|
||||
|
||||
class CourseChapter(Document):
|
||||
pass
|
||||
def on_update(self):
|
||||
self.recalculate_course_progress()
|
||||
update_course_statistics()
|
||||
|
||||
def recalculate_course_progress(self):
|
||||
previous_lessons = (
|
||||
self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
|
||||
)
|
||||
current_lessons = self.lessons
|
||||
|
||||
if previous_lessons and previous_lessons != current_lessons:
|
||||
enrolled_members = frappe.get_all(
|
||||
"LMS Enrollment", {"course": self.course}, ["member", "name"]
|
||||
)
|
||||
for enrollment in enrolled_members:
|
||||
new_progress = get_course_progress(self.course, enrollment.member)
|
||||
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestCourseEvaluator(FrappeTestCase):
|
||||
class TestCourseEvaluator(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -8,12 +8,18 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"chapter",
|
||||
"course",
|
||||
"column_break_4",
|
||||
"title",
|
||||
"include_in_preview",
|
||||
"index_label",
|
||||
"column_break_4",
|
||||
"chapter",
|
||||
"is_scorm_package",
|
||||
"course",
|
||||
"section_break_11",
|
||||
"content",
|
||||
"body",
|
||||
"column_break_cjmf",
|
||||
"instructor_content",
|
||||
"instructor_notes",
|
||||
"section_break_6",
|
||||
"youtube",
|
||||
"column_break_9",
|
||||
@@ -22,13 +28,7 @@
|
||||
"question",
|
||||
"column_break_15",
|
||||
"file_type",
|
||||
"section_break_11",
|
||||
"content",
|
||||
"body",
|
||||
"column_break_cjmf",
|
||||
"instructor_content",
|
||||
"instructor_notes",
|
||||
"help_section",
|
||||
"column_break_syza",
|
||||
"help"
|
||||
],
|
||||
"fields": [
|
||||
@@ -55,15 +55,10 @@
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "index_label",
|
||||
"fieldtype": "Data",
|
||||
"label": "Index Label",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_6",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -73,14 +68,7 @@
|
||||
"fieldname": "body",
|
||||
"fieldtype": "Markdown Editor",
|
||||
"ignore_xss_filter": 1,
|
||||
"label": "Body",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "help_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 1,
|
||||
"label": "Help"
|
||||
"label": "Body"
|
||||
},
|
||||
{
|
||||
"fieldname": "help",
|
||||
@@ -157,11 +145,23 @@
|
||||
"fieldname": "instructor_content",
|
||||
"fieldtype": "Text",
|
||||
"label": "Instructor Content"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_syza",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fetch_from": "chapter.is_scorm_package",
|
||||
"fieldname": "is_scorm_package",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is SCORM Package",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-03 10:48:17.525859",
|
||||
"modified": "2024-11-14 13:46:56.838659",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Lesson",
|
||||
|
||||
@@ -52,7 +52,6 @@ class CourseLesson(Document):
|
||||
ex.lesson = None
|
||||
ex.course = None
|
||||
ex.index_ = 0
|
||||
ex.index_label = ""
|
||||
ex.save(ignore_permissions=True)
|
||||
|
||||
def check_and_create_folder(self):
|
||||
@@ -94,15 +93,15 @@ def save_progress(lesson, course):
|
||||
|
||||
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
||||
|
||||
quiz_completed = get_quiz_progress(lesson)
|
||||
if not quiz_completed:
|
||||
return 0
|
||||
|
||||
if frappe.db.exists(
|
||||
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
||||
):
|
||||
return
|
||||
|
||||
quiz_completed = get_quiz_progress(lesson)
|
||||
if not quiz_completed:
|
||||
return 0
|
||||
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Course Progress",
|
||||
|
||||
@@ -15,20 +15,22 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Assessment Type",
|
||||
"options": "DocType"
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "assessment_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Assessment Name",
|
||||
"options": "assessment_type"
|
||||
"options": "assessment_type",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-05-29 14:56:36.602399",
|
||||
"modified": "2024-10-11 19:16:01.630524",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assessment",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSAssignment(FrappeTestCase):
|
||||
class TestLMSAssignment(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSBadge(FrappeTestCase):
|
||||
class TestLMSBadge(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSBadgeAssignment(FrappeTestCase):
|
||||
class TestLMSBadgeAssignment(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -193,13 +193,15 @@
|
||||
"depends_on": "paid_batch",
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount"
|
||||
"label": "Amount",
|
||||
"mandatory_depends_on": "paid_batch"
|
||||
},
|
||||
{
|
||||
"depends_on": "paid_batch",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"mandatory_depends_on": "paid_batch",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
@@ -328,7 +330,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-07-18 18:06:37.229885",
|
||||
"modified": "2024-11-18 16:28:41.336928",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch",
|
||||
|
||||
@@ -28,11 +28,13 @@ class LMSBatch(Document):
|
||||
self.validate_duplicate_courses()
|
||||
self.validate_duplicate_students()
|
||||
self.validate_payments_app()
|
||||
self.validate_amount_and_currency()
|
||||
self.validate_duplicate_assessments()
|
||||
self.validate_membership()
|
||||
self.validate_timetable()
|
||||
self.send_confirmation_mail()
|
||||
self.validate_evaluation_end_date()
|
||||
self.add_students_to_live_class()
|
||||
|
||||
def validate_batch_end_date(self):
|
||||
if self.end_date < self.start_date:
|
||||
@@ -63,6 +65,10 @@ class LMSBatch(Document):
|
||||
if "payments" not in installed_apps:
|
||||
frappe.throw(_("Please install the Payments app to create a paid batches."))
|
||||
|
||||
def validate_amount_and_currency(self):
|
||||
if self.paid_batch and (not self.amount or not self.currency):
|
||||
frappe.throw(_("Amount and currency are required for paid batches."))
|
||||
|
||||
def validate_duplicate_assessments(self):
|
||||
assessments = [row.assessment_name for row in self.assessment]
|
||||
for assessment in self.assessment:
|
||||
@@ -139,6 +145,27 @@ class LMSBatch(Document):
|
||||
if cint(self.seat_count) < len(self.students):
|
||||
frappe.throw(_("There are no seats available in this batch."))
|
||||
|
||||
def add_students_to_live_class(self):
|
||||
for student in self.students:
|
||||
if student.is_new():
|
||||
live_classes = frappe.get_all(
|
||||
"LMS Live Class", {"batch_name": self.name}, ["name", "event"]
|
||||
)
|
||||
|
||||
for live_class in live_classes:
|
||||
if live_class.event:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Participants",
|
||||
"reference_doctype": "User",
|
||||
"reference_docname": student.student,
|
||||
"email": student.student,
|
||||
"parent": live_class.event,
|
||||
"parenttype": "Event",
|
||||
"parentfield": "event_participants",
|
||||
}
|
||||
).save()
|
||||
|
||||
def validate_timetable(self):
|
||||
for schedule in self.timetable:
|
||||
if schedule.start_time and schedule.end_time:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSClass(FrappeTestCase):
|
||||
class TestLMSBatch(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
import unittest
|
||||
|
||||
|
||||
class TestLMSBatchTimetable(FrappeTestCase):
|
||||
class TestLMSBatchTimetable(unittest.TestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCategory(FrappeTestCase):
|
||||
class TestLMSCategory(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCertificateEvaluation(FrappeTestCase):
|
||||
class TestLMSCertificateEvaluation(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSCertificateRequest(FrappeTestCase):
|
||||
class TestLMSCertificateRequest(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
"certification_section",
|
||||
"enable_certification",
|
||||
"column_break_rxww",
|
||||
"expiry"
|
||||
"expiry",
|
||||
"tab_4_tab",
|
||||
"statistics_section",
|
||||
"enrollments",
|
||||
"lessons",
|
||||
"rating"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -249,6 +254,36 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Category",
|
||||
"options": "LMS Category"
|
||||
},
|
||||
{
|
||||
"fieldname": "tab_4_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Statistics"
|
||||
},
|
||||
{
|
||||
"fieldname": "statistics_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enrollments",
|
||||
"fieldtype": "Data",
|
||||
"label": "Enrollments",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "lessons",
|
||||
"fieldtype": "Data",
|
||||
"label": "Lessons",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "rating",
|
||||
"fieldtype": "Data",
|
||||
"label": "Rating",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -275,7 +310,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-09-21 10:23:58.633912",
|
||||
"modified": "2024-10-30 23:08:31.842860",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -19,6 +19,7 @@ class LMSCourse(Document):
|
||||
self.validate_video_link()
|
||||
self.validate_status()
|
||||
self.validate_payments_app()
|
||||
self.validate_amount_and_currency()
|
||||
self.image = validate_image(self.image)
|
||||
|
||||
def validate_published(self):
|
||||
@@ -51,6 +52,10 @@ class LMSCourse(Document):
|
||||
if "payments" not in installed_apps:
|
||||
frappe.throw(_("Please install the Payments app to create a paid courses."))
|
||||
|
||||
def validate_amount_and_currency(self):
|
||||
if self.paid_course and (not self.course_price and not self.currency):
|
||||
frappe.throw(_("Amount and currency are required for paid courses."))
|
||||
|
||||
def on_update(self):
|
||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||
self.send_email_to_interested_users()
|
||||
@@ -187,192 +192,3 @@ def reindex_exercises(doc):
|
||||
course = frappe.get_doc("LMS Course", course_data["name"])
|
||||
course.reindex_exercises()
|
||||
frappe.msgprint("All exercises in this course have been re-indexed.")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def search_course(text):
|
||||
courses = frappe.get_all(
|
||||
"LMS Course",
|
||||
filters={"published": True},
|
||||
or_filters={
|
||||
"title": ["like", f"%{text}%"],
|
||||
"tags": ["like", f"%{text}%"],
|
||||
"short_introduction": ["like", f"%{text}%"],
|
||||
"description": ["like", f"%{text}%"],
|
||||
},
|
||||
fields=["name", "title"],
|
||||
)
|
||||
return courses
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def submit_for_review(course):
|
||||
chapters = frappe.get_all("Chapter Reference", {"parent": course})
|
||||
if not len(chapters):
|
||||
return "No Chp"
|
||||
frappe.db.set_value("LMS Course", course, "status", "Under Review")
|
||||
return "OK"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_course(
|
||||
tags,
|
||||
title,
|
||||
short_introduction,
|
||||
video_link,
|
||||
description,
|
||||
course,
|
||||
published,
|
||||
upcoming,
|
||||
image=None,
|
||||
paid_course=False,
|
||||
course_price=None,
|
||||
currency=None,
|
||||
):
|
||||
if not can_create_courses(course):
|
||||
return
|
||||
|
||||
if course:
|
||||
doc = frappe.get_doc("LMS Course", course)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "LMS Course"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"title": title,
|
||||
"short_introduction": short_introduction,
|
||||
"video_link": video_link,
|
||||
"image": image,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"published": cint(published),
|
||||
"upcoming": cint(upcoming),
|
||||
"paid_course": cint(paid_course),
|
||||
"course_price": course_price,
|
||||
"currency": currency,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_chapter(course, title, chapter_description, idx, chapter):
|
||||
if chapter:
|
||||
doc = frappe.get_doc("Course Chapter", chapter)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Chapter"})
|
||||
|
||||
doc.update({"course": course, "title": title, "description": chapter_description})
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if chapter:
|
||||
chapter_reference = frappe.get_doc("Chapter Reference", {"chapter": chapter})
|
||||
else:
|
||||
chapter_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Chapter Reference",
|
||||
"parent": course,
|
||||
"parenttype": "LMS Course",
|
||||
"parentfield": "chapters",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
chapter_reference.update({"chapter": doc.name})
|
||||
chapter_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def save_lesson(
|
||||
title,
|
||||
body,
|
||||
chapter,
|
||||
preview,
|
||||
idx,
|
||||
lesson,
|
||||
instructor_notes=None,
|
||||
youtube=None,
|
||||
quiz_id=None,
|
||||
question=None,
|
||||
file_type=None,
|
||||
):
|
||||
if lesson:
|
||||
doc = frappe.get_doc("Course Lesson", lesson)
|
||||
else:
|
||||
doc = frappe.get_doc({"doctype": "Course Lesson"})
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"chapter": chapter,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"instructor_notes": instructor_notes,
|
||||
"include_in_preview": preview,
|
||||
"youtube": youtube,
|
||||
"quiz_id": quiz_id,
|
||||
"question": question,
|
||||
"file_type": file_type,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
if lesson:
|
||||
lesson_reference = frappe.get_doc("Lesson Reference", {"lesson": lesson})
|
||||
else:
|
||||
lesson_reference = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Lesson Reference",
|
||||
"parent": chapter,
|
||||
"parenttype": "Course Chapter",
|
||||
"parentfield": "lessons",
|
||||
"idx": idx,
|
||||
}
|
||||
)
|
||||
|
||||
lesson_reference.update({"lesson": doc.name})
|
||||
lesson_reference.save(ignore_permissions=True)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_lesson(old_chapter, old_lesson_array, new_chapter, new_lesson_array):
|
||||
if old_chapter == new_chapter:
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
else:
|
||||
sort_lessons(old_chapter, old_lesson_array)
|
||||
sort_lessons(new_chapter, new_lesson_array)
|
||||
|
||||
|
||||
def sort_lessons(chapter, lesson_array):
|
||||
lesson_array = json.loads(lesson_array)
|
||||
for les in lesson_array:
|
||||
ref = frappe.get_all("Lesson Reference", {"lesson": les}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Lesson Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"parent": chapter,
|
||||
"idx": lesson_array.index(les) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def reorder_chapter(chapter_array):
|
||||
chapter_array = json.loads(chapter_array)
|
||||
|
||||
for chap in chapter_array:
|
||||
ref = frappe.get_all("Chapter Reference", {"chapter": chap}, ["name", "idx"])
|
||||
if ref:
|
||||
frappe.db.set_value(
|
||||
"Chapter Reference",
|
||||
ref[0].name,
|
||||
{
|
||||
"idx": chapter_array.index(chap) + 1,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Course",
|
||||
"options": "LMS Course",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_lesson",
|
||||
@@ -126,7 +127,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-14 14:50:08.405033",
|
||||
"modified": "2024-10-30 12:44:16.103598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import ceil
|
||||
|
||||
|
||||
class LMSEnrollment(Document):
|
||||
@@ -11,6 +12,9 @@ class LMSEnrollment(Document):
|
||||
self.validate_membership_in_same_batch()
|
||||
self.validate_membership_in_different_batch_same_course()
|
||||
|
||||
def on_update(self):
|
||||
self.update_program_progress()
|
||||
|
||||
def validate_membership_in_same_batch(self):
|
||||
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
|
||||
if self.batch_old:
|
||||
@@ -55,6 +59,26 @@ class LMSEnrollment(Document):
|
||||
)
|
||||
)
|
||||
|
||||
def update_program_progress(self):
|
||||
programs = frappe.get_all(
|
||||
"LMS Program Member", {"member": self.member}, ["parent", "name"]
|
||||
)
|
||||
|
||||
for program in programs:
|
||||
total_progress = 0
|
||||
courses = frappe.get_all(
|
||||
"LMS Program Course", {"parent": program.parent}, pluck="course"
|
||||
)
|
||||
for course in courses:
|
||||
progress = frappe.db.get_value(
|
||||
"LMS Enrollment", {"course": course, "member": self.member}, "progress"
|
||||
)
|
||||
progress = progress or 0
|
||||
total_progress += progress
|
||||
|
||||
average_progress = ceil(total_progress / len(courses))
|
||||
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_membership(
|
||||
|
||||
@@ -10,19 +10,20 @@
|
||||
"title",
|
||||
"host",
|
||||
"batch_name",
|
||||
"event",
|
||||
"column_break_astv",
|
||||
"date",
|
||||
"time",
|
||||
"duration",
|
||||
"section_break_glxh",
|
||||
"description",
|
||||
"section_break_glxh",
|
||||
"date",
|
||||
"duration",
|
||||
"column_break_spvt",
|
||||
"time",
|
||||
"timezone",
|
||||
"password",
|
||||
"auto_recording",
|
||||
"section_break_yrpq",
|
||||
"password",
|
||||
"start_url",
|
||||
"column_break_yokr",
|
||||
"auto_recording",
|
||||
"join_url"
|
||||
],
|
||||
"fields": [
|
||||
@@ -122,11 +123,19 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Auto Recording",
|
||||
"options": "No Recording\nLocal\nCloud"
|
||||
},
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Link",
|
||||
"label": "Event",
|
||||
"options": "Event",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-09 11:22:33.272341",
|
||||
"modified": "2024-11-11 18:59:26.396111",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Live Class",
|
||||
|
||||
@@ -16,6 +16,7 @@ class LMSLiveClass(Document):
|
||||
if calendar:
|
||||
event = self.create_event()
|
||||
self.add_event_participants(event, calendar)
|
||||
frappe.db.set_value(self.doctype, self.name, "event", event.name)
|
||||
|
||||
def create_event(self):
|
||||
start = f"{self.date} {self.time}"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSLiveClass(FrappeTestCase):
|
||||
class TestLMSLiveClass(UnitTestCase):
|
||||
pass
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"default": "0",
|
||||
"fieldname": "payment_received",
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Payment Received"
|
||||
},
|
||||
{
|
||||
@@ -140,7 +141,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-10-26 16:54:12.408274",
|
||||
"modified": "2024-10-31 15:33:39.420366",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestLMSPayment(FrappeTestCase):
|
||||
class TestLMSPayment(UnitTestCase):
|
||||
pass
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user