Merge pull request #1523 from pateljannat/issues-109

refactor: misc enhancements
This commit is contained in:
Jannat Patel
2025-05-22 12:12:57 +05:30
committed by GitHub
23 changed files with 493 additions and 277 deletions

View File

@@ -1,12 +1,15 @@
describe("Course Creation", () => { describe("Course Creation", () => {
it("creates a new course", () => { it("creates a new course", () => {
cy.login(); cy.login();
cy.wait(1000); cy.wait(500);
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course // Create a course
cy.get("button").contains("New").click(); cy.get("button").contains("New").click();
cy.wait(1000); cy.wait(500);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course"); cy.get("label").contains("Title").type("Test Course");
@@ -96,7 +99,8 @@ describe("Course Creation", () => {
// View Course // View Course
cy.wait(1000); cy.wait(1000);
cy.visit("/lms"); cy.visit("/lms");
cy.wait(500); cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses"); cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => { cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course"); cy.get("div").contains("Test Course");

View File

@@ -25,6 +25,7 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload"; import "cypress-file-upload";
import "cypress-real-events";
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
if (!email) { if (!email) {
@@ -68,3 +69,11 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
element.dispatchEvent(event); element.dispatchEvent(event);
}); });
}); });
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
});

View File

@@ -52,6 +52,7 @@ declare module 'vue' {
Evaluators: typeof import('./src/components/Evaluators.vue')['default'] Evaluators: typeof import('./src/components/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default'] ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default'] FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default'] IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default'] IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']

View File

@@ -27,7 +27,7 @@
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.143", "frappe-ui": "^0.1.146",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",

View File

@@ -181,7 +181,6 @@
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue' import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
@@ -244,6 +243,7 @@ const iconProps = {
onMounted(() => { onMounted(() => {
addNotifications() addNotifications()
setSidebarLinks() setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => { socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload() unreadNotifications.reload()
}) })
@@ -388,10 +388,6 @@ const deletePage = (link) => {
) )
} }
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem( localStorage.setItem(

View File

@@ -1,21 +1,22 @@
<template> <template>
<div v-if="user.data?.is_student"> <div v-if="user.data?.is_student">
<div <div>
v-if="feedbackList.data?.length" <div class="leading-5 mb-4">
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5" <div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
> >
{{ __('Thank you for providing your feedback!') }} {{ __('to view your feedback.') }}
</div> </div>
<div v-else class="flex justify-between items-center mb-5"> <div v-else>
<div class="text-lg font-semibold"> {{ __('Help us improve by providing your feedback.') }}
{{ __('Help Us Improve') }}
</div> </div>
<Button @click="submitFeedback()">
{{ __('Submit') }}
</Button>
</div> </div>
<div class="space-y-8"> <div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="flex items-center justify-between"> <div class="space-y-4">
<Rating <Rating
v-for="key in ratingKeys" v-for="key in ratingKeys"
v-model="feedback[key]" v-model="feedback[key]"
@@ -27,18 +28,22 @@
v-model="feedback.feedback" v-model="feedback.feedback"
type="textarea" type="textarea"
:label="__('Feedback')" :label="__('Feedback')"
:rows="7" :rows="9"
:readonly="readOnly" :readonly="readOnly"
/> />
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
</div> </div>
</div> </div>
<div v-else-if="feedbackList.data?.length"> <div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5"> <div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average of Feedback Received') }} {{ __('Average Feedback Received') }}
</div> </div>
<div class="flex items-center justify-between mb-10"> <div class="space-y-4">
<Rating <Rating
v-for="key in ratingKeys" v-for="key in ratingKeys"
v-model="average[key]" v-model="average[key]"
@@ -47,81 +52,32 @@
/> />
</div> </div>
<div class="text-lg font-semibold mb-5"> <Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('All Feedback') }} {{ __('View all feedback') }}
</Button>
</div> </div>
<ListView <div v-else class="text-ink-gray-7 mt-5 leading-5">
:columns="feedbackColumns"
:rows="feedbackList.data"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList.data"
class="group cursor-pointer feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
{{ __('No feedback received yet.') }} {{ __('No feedback received yet.') }}
</div> </div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template> </template>
<script setup> <script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue' import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
Avatar, import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
const user = inject('$user') const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value'] const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false) const readOnly = ref(false)
const average = reactive({}) const average = reactive({})
const feedback = reactive({}) const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -167,6 +123,7 @@ watch(
if (feedbackList.data.length) { if (feedbackList.data.length) {
let data = feedbackList.data let data = feedbackList.data
readOnly.value = true readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => { ratingKeys.forEach((key) => {
average[key] = 0 average[key] = 0
@@ -201,40 +158,11 @@ const submitFeedback = () => {
{ {
onSuccess: () => { onSuccess: () => {
feedbackList.reload() feedbackList.reload()
showFeedbackForm.value = false
}, },
} }
) )
} }
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script> </script>
<style> <style>
.feedback-list > button > div { .feedback-list > button > div {

View File

@@ -1,9 +1,24 @@
<template> <template>
<div class="flex flex-col min-h-0"> <div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between mb-5">
<div class="text-xl font-semibold mb-5 text-ink-gray-9"> <div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }} {{ label }}
</div> </div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<div
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
v-if="saving"
>
<LoadingIndicator class="size-2" />
<span class="text-xs">
{{ __('saving...') }}
</span>
</div>
<Button @click="() => showCategoryForm()"> <Button @click="() => showCategoryForm()">
<template #prefix> <template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
@@ -12,6 +27,7 @@
{{ showForm ? __('Close') : __('New') }} {{ showForm ? __('Close') : __('New') }}
</Button> </Button>
</div> </div>
</div>
<div <div
v-if="showForm" v-if="showForm"
@@ -29,31 +45,63 @@
</div> </div>
<div class="overflow-y-scroll"> <div class="overflow-y-scroll">
<div class="text-base space-y-2"> <div class="divide-y space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
class="pt-4 pb-1"
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group"
>
<div @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
variant="ghost"
theme="red"
class="invisible group-hover:visible"
@click="deleteCategory(cat.name)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
</template>
</Button>
</div>
<FormControl <FormControl
:value="cat.category" v-else
:ref="(el) => (editInputRef[index] = el)"
v-model="editedValue"
type="text" type="text"
v-for="cat in categories.data" class="w-full"
@change.stop="(e) => update(cat.name, e.target.value)" @keyup.enter="saveChanges(cat.name, editedValue)"
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
Button, Button,
FormControl, FormControl,
LoadingIndicator,
createListResource, createListResource,
createResource, createResource,
debounce, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next' import { Plus, Trash2, X } from 'lucide-vue-next'
import { ref } from 'vue' import { ref } from 'vue'
import { cleanError } from '@/utils'
const showForm = ref(false) const showForm = ref(false)
const category = ref(null) const category = ref(null)
const categoryInput = ref(null) const categoryInput = ref(null)
const saving = ref(false)
const editing = ref(null)
const editedValue = ref('')
const editInputRef = ref([])
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -72,25 +120,20 @@ const categories = createListResource({
auto: true, auto: true,
}) })
const newCategory = createResource({ const addCategory = () => {
url: 'frappe.client.insert', categories.insert.submit(
makeParams(values) { {
return {
doc: {
doctype: 'LMS Category',
category: category.value, category: category.value,
}, },
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{ {
onSuccess(data) { onSuccess(data) {
categories.reload() categories.reload()
category.value = null category.value = null
showForm.value = false
toast.success(__('Category added successfully'))
},
onError(err) {
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
}, },
} }
) )
@@ -115,6 +158,7 @@ const updateCategory = createResource({
}) })
const update = (name, value) => { const update = (name, value) => {
saving.value = true
updateCategory.submit( updateCategory.submit(
{ {
name: name, name: name,
@@ -122,9 +166,51 @@ const update = (name, value) => {
}, },
{ {
onSuccess() { onSuccess() {
saving.value = false
categories.reload() categories.reload()
editing.value = null
editedValue.value = ''
toast.success(__('Category updated successfully'))
},
onError(err) {
saving.value = false
editing.value = null
editedValue.value = ''
toast.error(
__(cleanError(err.messages[0]) || 'Unable to update category')
)
}, },
} }
) )
} }
const deleteCategory = (name) => {
saving.value = true
categories.delete.submit(name, {
onSuccess() {
saving.value = false
categories.reload()
toast.success(__('Category deleted successfully'))
},
onError(err) {
saving.value = false
toast.error(
__(cleanError(err.messages[0]) || 'Unable to delete category')
)
},
})
}
const saveChanges = (name, value) => {
saving.value = true
update(name, value)
}
const allowEdit = (cat, index) => {
editing.value = cat
editedValue.value = cat.category
setTimeout(() => {
editInputRef.value[index].$el.querySelector('input').focus()
}, 0)
}
</script> </script>

View File

@@ -116,7 +116,7 @@
v-if="parseInt(course.data.rating) > 0" v-if="parseInt(course.data.rating) > 0"
class="flex items-center text-ink-gray-9" class="flex items-center text-ink-gray-9"
> >
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" /> <Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }} {{ course.data.rating }} {{ __('Rating') }}
</span> </span>

View File

@@ -35,14 +35,14 @@
<span class="text-ink-gray-7"> <span class="text-ink-gray-7">
{{ review.creation }} {{ review.creation }}
</span> </span>
<div class="flex mt-2"> <div class="flex mt-2 space-x-1">
<Star <Star
v-for="index in 5" v-for="index in 5"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2" class="size-4 text-transparent rounded-sm"
:class=" :class="
index <= Math.ceil(review.rating) index <= Math.ceil(review.rating)
? 'fill-orange-500' ? 'fill-yellow-500'
: 'fill-gray-600' : 'fill-gray-300'
" "
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9"> <div class="text-xl font-semibold mb-1 text-ink-gray-9">
@@ -39,6 +39,7 @@
</Button> </Button>
</div> </div>
<div class="overflow-y-scroll">
<div class="divide-y"> <div class="divide-y">
<div <div
v-for="evaluator in evaluators.data" v-for="evaluator in evaluators.data"
@@ -65,6 +66,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui' import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'

View File

@@ -0,0 +1,115 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList"
class="group feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { reactive, computed } from 'vue'
const show = defineModel()
const ratingKeys = ['content', 'instructors', 'value']
const props = defineProps({
feedbackList: {
type: Array,
required: true,
},
})
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>

View File

@@ -15,26 +15,20 @@
> >
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <Rating v-model="review.rating" :label="__('Rating')" />
<div class="mb-1.5 text-sm text-ink-gray-5"> <FormControl
{{ __('Rating') }} :label="__('Review')"
</div> type="textarea"
<Rating v-model="review.rating" /> v-model="review.review"
</div> :rows="5"
<div> />
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Review') }}
</div>
<Textarea type="text" size="md" rows="5" v-model="review.review" />
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, Textarea, createResource, toast } from 'frappe-ui' import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue'
const show = defineModel() const show = defineModel()
const reviews = defineModel('reloadReviews') const reviews = defineModel('reloadReviews')

View File

@@ -207,7 +207,7 @@ const tabsStructure = computed(() => {
}, },
{ {
label: 'Categories', label: 'Categories',
description: 'Manage the members of your learning system', description: 'Double click to edit the category',
icon: 'Network', icon: 'Network',
}, },
], ],

View File

@@ -88,16 +88,14 @@
:scrollToBottom="false" :scrollToBottom="false"
/> />
</div> </div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
</div> </div>
<div class="p-5"> <div class="p-5">
<div class="text-ink-gray-7 font-semibold mb-4"> <div class="mb-10">
{{ __('About this batch') }}: <div class="text-ink-gray-7 font-semibold mb-2">
{{ __('About this batch') }}
</div> </div>
<div <div
v-html="batch.data.description" v-html="batch.data.description"
@@ -140,6 +138,13 @@
</span> </span>
</div> </div>
</div> </div>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
</div>
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
<AnnouncementModal <AnnouncementModal
v-model="showAnnouncementModal" v-model="showAnnouncementModal"
:batch="batch.data.name" :batch="batch.data.name"
@@ -234,6 +239,7 @@ import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue' import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue' import BatchFeedback from '@/components/BatchFeedback.vue'
import dayjs from 'dayjs/esm'
const user = inject('$user') const user = inject('$user')
const showAnnouncementModal = ref(false) const showAnnouncementModal = ref(false)
@@ -277,11 +283,6 @@ const tabs = computed(() => {
label: 'Discussions', label: 'Discussions',
icon: MessageCircle, icon: MessageCircle,
}) })
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs return batchTabs
}) })

View File

@@ -20,14 +20,12 @@
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
v-if="batchCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Batches') }} {{ __('All Batches') }}
</div> </div>
<div <div
v-if="batches.data?.length || batchCount"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons <TabButtons
@@ -115,12 +113,10 @@ const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming') const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const batchCount = ref(0)
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
updateBatches() updateBatches()
getBatchCount()
categories.value = [ categories.value = [
{ {
label: '', label: '',
@@ -298,14 +294,6 @@ const canCreateBatch = () => {
return false return false
} }
const getBatchCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Batch',
}).then((data) => {
batchCount.value = data
})
}
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Batches'), label: __('Batches'),

View File

@@ -20,7 +20,7 @@
:text="__('Average Rating')" :text="__('Average Rating')"
class="flex items-center" class="flex items-center"
> >
<Star class="h-5 w-5 text-gray-100 fill-orange-500" /> <Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7"> <span class="ml-1 text-ink-gray-7">
{{ course.data.rating }} {{ course.data.rating }}
</span> </span>

View File

@@ -20,14 +20,12 @@
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
v-if="courseCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Courses') }} {{ __('All Courses') }}
</div> </div>
<div <div
v-if="courses.data?.length || courseCount"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons :buttons="courseTabs" v-model="currentTab" /> <TabButtons :buttons="courseTabs" v-model="currentTab" />
@@ -172,6 +170,8 @@ const identifyUserPersona = async () => {
} }
const getCourseCount = () => { const getCourseCount = () => {
if (!user.data) return
call('frappe.client.get_count', { call('frappe.client.get_count', {
doctype: 'LMS Course', doctype: 'LMS Course',
}).then((data) => { }).then((data) => {

View File

@@ -561,3 +561,24 @@ export const openSettings = (category, close) => {
settingsStore.activeTab = category settingsStore.activeTab = category
settingsStore.isSettingsOpen = true settingsStore.isSettingsOpen = true
} }
export const cleanError = (message) => {
// Remove HTML tags but keep the text within the tags
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
return match.replace(/<\/?[^>]+(>|$)/g, '')
})
return cleanMessage
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#x60;/g, '`')
.replace(/&#x3D;/g, '=')
.replace(/&#x2F;/g, '/')
.replace(/&#x2C;/g, ',')
.replace(/&#x3B;/g, ';')
.replace(/&#x3A;/g, ':')
}

View File

@@ -356,6 +356,7 @@
"fieldtype": "Section Break" "fieldtype": "Section Break"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [ "links": [
{ {
@@ -371,8 +372,8 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-02-18 15:43:18.512504", "modified": "2025-05-21 13:30:28.904260",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",
"owner": "Administrator", "owner": "Administrator",
@@ -412,8 +413,18 @@
"role": "Batch Evaluator", "role": "Batch Evaluator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",

View File

@@ -103,7 +103,7 @@ class LMSBatch(Document):
frappe.throw(_("Seat count cannot be negative.")) frappe.throw(_("Seat count cannot be negative."))
students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name}) students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name})
if cint(self.seat_count) < students: if cint(self.seat_count) and cint(self.seat_count) < students:
frappe.throw(_("There are no seats available in this batch.")) frappe.throw(_("There are no seats available in this batch."))
def validate_timetable(self): def validate_timetable(self):

View File

@@ -73,10 +73,11 @@
"read_only": 1 "read_only": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-01-13 19:02:58.259908", "modified": "2025-05-21 15:58:51.667270",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch Feedback", "name": "LMS Batch Feedback",
"owner": "Administrator", "owner": "Administrator",
@@ -106,7 +107,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"title_field": "member"
} }

View File

@@ -26,7 +26,8 @@
"homepage": "https://github.com/frappe/lms#readme", "homepage": "https://github.com/frappe/lms#readme",
"devDependencies": { "devDependencies": {
"cypress": "^13.9.0", "cypress": "^13.9.0",
"cypress-file-upload": "^5.0.8" "cypress-file-upload": "^5.0.8",
"cypress-real-events": "^1.14.0"
}, },
"dependencies": { "dependencies": {
"pre-commit": "^1.2.2" "pre-commit": "^1.2.2"

View File

@@ -2107,6 +2107,11 @@ cypress-file-upload@^5.0.8:
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1" resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1"
integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g== integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==
cypress-real-events@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.14.0.tgz#c5495db50a2bd247f4accde983af7153566945d3"
integrity sha512-XmI8y3OZLh6cjRroPalzzS++iv+pGCaD9G9kfIbtspgv7GVsDt30dkZvSXfgZb4rAN+3pOkMVB7e0j4oXydW7Q==
cypress@^13.9.0: cypress@^13.9.0:
version "13.17.0" version "13.17.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.17.0.tgz#34c3d68080c4497eace0f353bd1629587a5f600d" resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.17.0.tgz#34c3d68080c4497eace0f353bd1629587a5f600d"
@@ -2699,6 +2704,57 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.146:
version "0.1.146"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.146.tgz#3aadc0911ed02aefaa47df353a65a70a18f660fb"
integrity sha512-alka2Ru3+ZRTX3XJJCesWYjtZJwGCWeasInZGj1YYGEDxFRlRxg7THLtSAJ3ubGyWqgvAXW5ua/xBySLua00mQ==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
"@popperjs/core" "^2.11.2"
"@tailwindcss/forms" "^0.5.3"
"@tailwindcss/typography" "^0.5.16"
"@tiptap/core" "^2.11.7"
"@tiptap/extension-code-block" "^2.11.9"
"@tiptap/extension-code-block-lowlight" "^2.11.5"
"@tiptap/extension-color" "^2.0.3"
"@tiptap/extension-highlight" "^2.0.3"
"@tiptap/extension-image" "^2.0.3"
"@tiptap/extension-link" "^2.0.3"
"@tiptap/extension-mention" "^2.0.3"
"@tiptap/extension-placeholder" "^2.0.3"
"@tiptap/extension-table" "^2.0.3"
"@tiptap/extension-table-cell" "^2.0.3"
"@tiptap/extension-table-header" "^2.0.3"
"@tiptap/extension-table-row" "^2.0.3"
"@tiptap/extension-text-align" "^2.0.3"
"@tiptap/extension-text-style" "^2.0.3"
"@tiptap/extension-typography" "^2.0.3"
"@tiptap/pm" "^2.0.3"
"@tiptap/starter-kit" "^2.0.3"
"@tiptap/suggestion" "^2.0.3"
"@tiptap/vue-3" "^2.0.3"
"@vueuse/core" "^10.4.1"
dayjs "^1.11.13"
echarts "^5.6.0"
feather-icons "^4.28.0"
idb-keyval "^6.2.0"
lowlight "^3.3.0"
lucide-static "^0.479.0"
ora "5.4.1"
prettier "^3.3.2"
prosemirror-model "^1.25.1"
prosemirror-state "^1.4.3"
prosemirror-view "^1.39.2"
radix-vue "^1.5.3"
reka-ui "^2.0.2"
showdown "^2.1.0"
socket.io-client "^4.5.1"
tippy.js "^6.3.7"
typescript "^5.0.2"
unplugin-icons "^22.1.0"
unplugin-vue-components "^28.4.1"
fs-extra@^10.1.0: fs-extra@^10.1.0:
version "10.1.0" version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"