chore: capture more events for analytics

(cherry picked from commit b3c8fbd833)

# Conflicts:
#	frontend/src/pages/Batches/components/NewBatchModal.vue
#	lms/lms/doctype/lms_course_review/lms_course_review.json
This commit is contained in:
Jannat Patel
2026-02-23 16:31:58 +05:30
committed by Mergify
parent 9b0a7f5fa5
commit 22e005f19c
8 changed files with 321 additions and 155 deletions
+59 -80
View File
@@ -93,11 +93,19 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui' import {
call,
createResource,
TextEditor,
Button,
Dropdown,
toast,
} from 'frappe-ui'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, onUnmounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
import { useTelemetry } from 'frappe-ui/frappe'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
const mentionUsers = ref([]) const mentionUsers = ref([])
const renderEditor = ref(false) const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -143,19 +152,6 @@ const replies = createResource({
auto: true, auto: true,
}) })
const newReplyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
},
}
},
})
const fetchMentionUsers = () => { const fetchMentionUsers = () => {
if (user.data?.is_student) { if (user.data?.is_student) {
renderEditor.value = true renderEditor.value = true
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
} }
const postReply = () => { const postReply = () => {
newReplyResource.submit( if (!newReply.value) {
{}, toast.error(__('Reply cannot be empty.'))
{ return
validate() { }
if (!newReply.value) { call('frappe.client.insert', {
return 'Reply cannot be empty' doc: {
}
},
onSuccess() {
newReply.value = ''
replies.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Discussion Reply', doctype: 'Discussion Reply',
name: values.name, reply: newReply.value,
fieldname: 'reply', topic: props.topic.name,
value: values.reply, },
} })
}, .then((data) => {
}) newReply.value = ''
replies.reload()
capture('discussion_reply_created')
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const postEdited = (reply) => { const postEdited = (reply) => {
editReplyResource.submit( if (!reply.reply) {
{ toast.error(__('Reply cannot be empty.'))
name: reply.name, return
reply: reply.reply, }
}, call('frappe.client.set_value', {
{ doctype: 'Discussion Reply',
validate() { name: reply.name,
if (!reply.reply) { fieldname: 'reply',
return 'Reply cannot be empty' value: reply.reply,
} })
}, .then(() => {
onSuccess() { reply.editable = false
reply.editable = false replies.reload()
replies.reload() })
}, .catch((err) => {
} toast.error(err.messages?.[0] || err)
) console.error(err)
})
} }
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
})
const deleteReply = (reply) => { const deleteReply = (reply) => {
deleteReplyResource.submit( call('frappe.client.delete', {
{ doctype: 'Discussion Reply',
name: reply.name, name: reply.name,
}, })
{ .then(() => {
onSuccess() { replies.reload()
replies.reload() })
}, .catch((err) => {
} toast.error(err.messages?.[0] || err)
) console.error(err)
})
} }
onUnmounted(() => { onUnmounted(() => {
@@ -34,17 +34,13 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { singularize } from '@/utils' import { singularize } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
const topics = defineModel('reloadTopics') const topics = defineModel('reloadTopics')
const { capture } = useTelemetry()
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -66,64 +62,50 @@ const topic = reactive({
reply: '', reply: '',
}) })
const topicResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
}
},
})
const replyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
topic: values.topic,
reply: topic.reply,
},
}
},
})
const submitTopic = (close) => { const submitTopic = (close) => {
topicResource.submit( if (!topic.title) {
{}, toast.error(__('Title cannot be empty.'))
{ return
validate() { }
if (!topic.title) { if (!topic.reply) {
return 'Title cannot be empty.' toast.error(__('Details cannot be empty.'))
} return
if (!topic.reply) { }
return 'Reply cannot be empty.' call('frappe.client.insert', {
} doc: {
}, doctype: 'Discussion Topic',
onSuccess(data) { reference_doctype: props.doctype,
replyResource.submit( reference_docname: props.docname,
{ title: topic.title,
topic: data.name, },
}, })
{ .then((data) => {
onSuccess() { createReply(data.name, close)
topic.title = '' })
topic.reply = '' .catch((err) => {
topics.value.reload() toast.error(err.messages?.[0] || err)
close() console.error(err)
}, })
} }
)
}, const createReply = (topicName, close) => {
onError(err) { call('frappe.client.insert', {
toast.error(err.messages?.[0] || err) doc: {
}, doctype: 'Discussion Reply',
} topic: topicName,
) reply: topic.reply,
},
})
.then((data) => {
topic.title = ''
topic.reply = ''
topics.value.reload()
capture('discussion_topic_created')
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
} }
</script> </script>
@@ -131,6 +131,7 @@ import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next' import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types' import type { User } from '@/components/Settings/types'
import { useTelemetry } from 'frappe-ui/frappe'
type Member = { type Member = {
username: string username: string
@@ -149,6 +150,7 @@ const hasNextPage = ref(false)
const showForm = ref(false) const showForm = ref(false)
const user = inject<User | null>('$user') const user = inject<User | null>('$user')
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const { capture } = useTelemetry()
const member = reactive({ const member = reactive({
email: '', email: '',
@@ -202,6 +204,7 @@ const addMember = (close: () => void) => {
}) })
.then((data: Member) => { .then((data: Member) => {
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students') if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
capture('user_added')
show.value = false show.value = false
router.push({ router.push({
name: 'ProfileRoles', name: 'ProfileRoles',
@@ -0,0 +1,199 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('New Batch'),
size: '3xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="batch.category"
:label="__('Category')"
:allowCreate="true"
@create="
() => {
openSettings('Categories')
show = false
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
:required="true"
/>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
:required="false"
/>
</div>
</div>
<div class="space-y-5 border-t mt-5 pt-5">
<MultiSelect
v-model="batch.instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="batch.batch_details"
@change="(val: string) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="saveBatch(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { ref, inject, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const props = defineProps<{
batches: any
}>()
const batch = ref({
title: '',
start_date: null,
end_date: null,
start_time: null,
end_time: null,
timezone: null,
description: '',
batch_details: '',
instructors: [],
category: null,
seat_count: 0,
})
const saveBatch = (close: () => void = () => {}) => {
props.batches.insert.submit(
{
...batch.value,
instructors: batch.value.instructors.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data: any) {
toast.success(__('Batch created successfully'))
close()
capture('batch_created')
router.push({
name: 'BatchDetail',
params: { batchName: data.name },
hash: '#settings',
})
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_batch', true, false, () => {
localStorage.setItem('firstBatch', data.name)
})
}
},
onError(err: any) {
toast.error(cleanError(err.messages?.[0]))
console.error(err)
},
}
)
}
const keyboardShortcut = (e: KeyboardEvent) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
e.target &&
e.target instanceof HTMLElement &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
capture('batch_form_opened')
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
capture('batch_form_closed', {
data: batch.value,
})
})
</script>
@@ -76,7 +76,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui' import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe' import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils' import { cleanError, openSettings } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -148,13 +148,13 @@ const keyboardShortcut = (e: KeyboardEvent) => {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
capture('course_form_opened')
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
}) capture('course_form_closed', {
data: course.value,
watch(show, () => { })
if (show.value) capture('course_form_opened')
}) })
</script> </script>
@@ -103,7 +103,7 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None):
) )
progress = get_course_progress(course) progress = get_course_progress(course)
capture_progress_for_analytics(progress, course) capture_progress_for_analytics()
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned. # Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership) enrollment = frappe.get_doc("LMS Enrollment", membership)
@@ -121,9 +121,8 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None):
return progress return progress
def capture_progress_for_analytics(progress, course): def capture_progress_for_analytics():
if progress in [25, 50, 75, 100]: capture("course_progress", "lms")
capture("course_progress", "lms", properties={"course": course, "progress": progress})
def get_quiz_progress(lesson): def get_quiz_progress(lesson):
@@ -18,8 +18,8 @@ class LMSCertificate(Document):
self.name = make_autoname("hash", self.doctype) self.name = make_autoname("hash", self.doctype)
def after_insert(self): def after_insert(self):
self.send_certification_email()
capture("certificate_issued", "lms") capture("certificate_issued", "lms")
self.send_certification_email()
def send_certification_email(self): def send_certification_email(self):
outgoing_email_account = frappe.get_cached_value( outgoing_email_account = frappe.get_cached_value(
@@ -41,7 +41,11 @@
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
<<<<<<< HEAD
"modified": "2026-01-29 16:10:47.787285", "modified": "2026-01-29 16:10:47.787285",
=======
"modified": "2026-02-23 16:21:18.503806",
>>>>>>> b3c8fbd8 (chore: capture more events for analytics)
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Course Review", "name": "LMS Course Review",