feat: javascript exercises

This commit is contained in:
Jannat Patel
2025-06-25 12:15:27 +05:30
parent 4fb0db7a1e
commit e71275a0dc
18 changed files with 387 additions and 103 deletions

View File

@@ -79,7 +79,6 @@ declare module 'vue' {
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgrammingExerciseModal: typeof import('./src/components/Modals/ProgrammingExerciseModal.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default']

View File

@@ -313,7 +313,7 @@ const addNotifications = () => {
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(4, 0, {
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
@@ -329,7 +329,7 @@ const addQuizzes = () => {
const addAssignments = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
sidebarLinks.value.splice(5, 0, {
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
@@ -343,22 +343,6 @@ const addAssignments = () => {
}
}
const addProgrammingExercises = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
})
}
}
const addPrograms = () => {
let activeFor = ['Programs', 'ProgramForm']
let index = 1
@@ -644,7 +628,6 @@ watch(userResource, () => {
addPrograms()
addQuizzes()
addAssignments()
addProgrammingExercises()
setUpOnboarding()
}
})

View File

@@ -231,7 +231,7 @@ const getAssessmentColumns = () => {
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'

View File

@@ -6,13 +6,12 @@
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<StudentHeatmap />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const props = defineProps({
batch: {

View File

@@ -8,6 +8,7 @@
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
>
<div class="flex items-center">
<slot name="prefix" />

View File

@@ -1,5 +1,8 @@
<template>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-x-auto border rounded-md">
<div
class="grid items-center space-x-4 p-2 border-b"
@@ -14,7 +17,6 @@
</div>
<div></div>
</div>
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
@@ -71,7 +73,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { Button } from 'frappe-ui'
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
@@ -93,6 +95,7 @@ const props = withDefaults(
defineProps<{
modelValue?: Cell[][]
columns?: string[]
label?: string
}>(),
{
columns: [],
@@ -101,6 +104,12 @@ const props = withDefaults(
const columns = ref(props.columns)
watch(rows, () => {
if (rows.value?.length < 1) {
addRow()
}
})
const addRow = () => {
if (!rows.value) {
rows.value = []

View File

@@ -12,6 +12,7 @@
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />

View File

@@ -138,7 +138,6 @@ watch(userResource, () => {
) {
addQuizzes()
addAssignments()
addProgrammingExercises()
}
})
@@ -158,14 +157,6 @@ const addAssignments = () => {
})
}
const addProgrammingExercises = () => {
otherLinks.value.push({
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}

View File

@@ -255,6 +255,9 @@ const saveEvaluation = () => {
}
toast.success(__('Evaluation saved successfully'))
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
@@ -277,6 +280,9 @@ const certificateResource = createResource({
onSuccess(data) {
certificate.name = data
},
onError(err) {
toast.warning(__(err.messages?.[0] || err))
},
})
const certificateDetails = createResource({
@@ -310,6 +316,9 @@ const saveCertificate = () => {
onSuccess: () => {
toast.success(__('Certificate saved successfully'))
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
},
}
)
}

View File

@@ -26,6 +26,7 @@
/>
<ChildTable
v-model="exercise.test_cases"
:label="__('Test Cases')"
:columns="testCaseColumns"
:required="true"
:addable="true"
@@ -52,7 +53,19 @@
</div>
</template>
<template #actions="{ close }">
<div class="flex justify-end space-x-2">
<div class="flex justify-end space-x-2 group">
<Button
v-if="exerciseID != 'new'"
@click="deleteExercise(close)"
variant="outline"
theme="red"
class="invisible group-hover:visible"
>
<template #prefix>
<Trash2 class="size-4 stroke-1.5" />
</template>
{{ __('Delete') }}
</Button>
<router-link
:to="{
name: 'ProgrammingExerciseSubmission',
@@ -63,6 +76,9 @@
}"
>
<Button>
<template #prefix>
<Play class="size-4 stroke-1.5" />
</template>
{{ __('Test this Exercise') }}
</Button>
</router-link>
@@ -75,6 +91,9 @@
}"
>
<Button>
<template #prefix>
<ClipboardList class="size-4 stroke-1.5" />
</template>
{{ __('Check Submission') }}
</Button>
</router-link>
@@ -95,22 +114,16 @@ import {
toast,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { ProgrammingExercise, TestCase } from '@/types/programming-exercise'
import {
ProgrammingExercise,
ProgrammingExercises,
TestCase,
} from '@/types/programming-exercise'
import ChildTable from '@/components/Controls/ChildTable.vue'
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
const show = defineModel()
const exercises = defineModel<{
data: ProgrammingExercise[]
reload: () => void
hasNextPage: boolean
next: () => void
setValue: {
submit: (
data: ProgrammingExercise,
options?: { onSuccess?: () => void }
) => void
}
}>('exercises')
const exercises = defineModel<ProgrammingExercises>('exercises')
const exercise = ref<ProgrammingExercise>({
title: '',
@@ -122,8 +135,6 @@ const exercise = ref<ProgrammingExercise>({
const languageOptions = [
{ label: 'Python', value: 'Python' },
{ label: 'JavaScript', value: 'JavaScript' },
{ label: 'Java', value: 'Java' },
{ label: 'C++', value: 'C++' },
]
const props = withDefaults(
@@ -138,19 +149,28 @@ const props = withDefaults(
watch(
() => props.exerciseID,
() => {
if (props.exerciseID != 'new') {
setExerciseData()
fetchTestCases()
}
setExerciseData()
fetchTestCases()
}
)
const setExerciseData = () => {
exercises.value?.data.forEach((ex) => {
let isNew = true
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
if (ex.name === props.exerciseID) {
isNew = false
exercise.value = { ...ex }
}
})
if (isNew) {
exercise.value = {
title: '',
language: 'Python',
problem_statement: '',
test_cases: [],
}
}
}
const testCases = createListResource({
@@ -180,22 +200,25 @@ const saveExercise = (close: () => void) => {
}
const createNewExercise = (close: () => void) => {
exercises.value.insert.submit(
exercises.value?.insert.submit(
{
...exercise.value,
},
{
onSuccess() {
close()
exercises.value.reload()
exercises.value?.reload()
toast.success(__('Programming Exercise created successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
const updateExercise = (close: () => void) => {
exercises.value.setValue.submit(
exercises.value?.setValue.submit(
{
name: props.exerciseID,
...exercise.value,
@@ -203,9 +226,12 @@ const updateExercise = (close: () => void) => {
{
onSuccess() {
close()
exercises.value.reload()
exercises.value?.reload()
toast.success(__('Programming Exercise updated successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
)
}
@@ -213,4 +239,17 @@ const updateExercise = (close: () => void) => {
const testCaseColumns = computed(() => {
return ['Input', 'Expected Output']
})
const deleteExercise = (close: () => void) => {
if (props.exerciseID == 'new') return
exercises.value?.delete.submit(props.exerciseID, {
onSuccess() {
toast.success(__('Programming Exercise deleted successfully'))
close()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
})
}
</script>

View File

@@ -6,10 +6,15 @@
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
<div
v-html="exercise.doc?.problem_statement"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal border-r px-5 py-2 h-full"
></div>
<div class="border-r py-5 px-8 h-full">
<div class="font-semibold mb-2">
{{ __('Problem Statement') }}
</div>
<div
v-html="exercise.doc?.problem_statement"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
></div>
</div>
<div>
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
<div class="font-semibold">
@@ -22,7 +27,13 @@
>
{{ submission.doc.status }}
</Badge>
<Button variant="solid" @click="submitCode">
<Button
v-if="
submissionID == 'new' || user.data?.name == submission.doc?.owner
"
variant="solid"
@click="submitCode"
>
<template #prefix>
<Play class="size-3" />
</template>
@@ -33,7 +44,7 @@
<div class="flex flex-col space-y-4 py-5 border-b">
<Code
v-model="code"
language="python"
:language="exercise.doc?.language.toLowerCase()"
height="400px"
maxHeight="1000px"
/>
@@ -43,23 +54,23 @@
<textarea
v-if="error"
v-model="errorMessage"
class="bg-surface-gray-1 border-none text-sm h-28 leading-6"
class="bg-surface-gray-1 border-none text-sm h-32 leading-6"
readonly
/>
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
</div>
<div ref="testCaseSection" v-if="testCases.length" class="p-3">
<div ref="testCaseSection" class="p-5">
<span class="text-lg font-semibold text-ink-gray-9">
{{ __('Test Cases') }}
</span>
<div class="divide-y mt-5">
<div v-if="testCases.length" class="divide-y mt-5">
<div
v-for="(testCase, index) in testCases"
:key="testCase.input"
class="py-3"
>
<div class="flex items-center mb-5">
<div class="flex items-center mb-3">
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
<span
class="font-semibold ml-2 mr-1"
@@ -71,16 +82,18 @@
>
{{ testCase.status }}
</span>
<span v-if="testCase.status === 'Passed'">
<!-- <span v-if="testCase.status === 'Passed'">
<Check class="size-4 text-ink-green-3" />
</span>
<span v-else>
<X class="size-4 text-ink-red-3" />
</span>
</span> -->
</div>
<div class="flex items-center justify-between w-[60%]">
<div v-if="testCase.input" class="space-y-2">
<div class="text-xs text-ink-gray-7">{{ __('Input') }}:</div>
<div class="text-xs text-ink-gray-7">
{{ __('Input') }}
</div>
<div>{{ testCase.input }}</div>
</div>
<div class="space-y-2">
@@ -100,6 +113,9 @@
</div>
</div>
</div>
<div v-else class="text-sm text-ink-gray-6 mt-4">
{{ __('Please run the code to execute the test cases.') }}
</div>
</div>
</div>
</div>
@@ -114,11 +130,12 @@ import {
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Play, X, Check } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
const user = inject<any>('$user')
const code = ref<string | null>('')
const output = ref<string | null>(null)
const error = ref<boolean | null>(null)
@@ -132,12 +149,11 @@ const testCases = ref<
status: string
}>
>([])
const boilerplate = ref<string>(
`with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
)
const boilerplate = ref<string>('')
const { brand } = sessionStore()
const router = useRouter()
const fromLesson = ref(false)
const falconURL = 'https://falcon.frappe.io/'
const props = withDefaults(
defineProps<{
@@ -151,16 +167,25 @@ const props = withDefaults(
onMounted(() => {
loadFalcon()
if (props.submissionID != 'new') {
submission.reload()
}
if (!code.value) {
code.value = boilerplate.value
}
checkIfUserIsPermitted()
checkIfInLesson()
fetchSubmission()
})
const checkIfInLesson = () => {
if (new URLSearchParams(window.location.search).get('fromLesson')) {
fromLesson.value = true
}
})
}
const fetchSubmission = (name: string = '') => {
if (name) {
submission.name = name
submission.reload()
} else if (props.submissionID != 'new') {
submission.reload()
}
}
const exercise = createDocumentResource({
doctype: 'LMS Programming Exercise',
@@ -172,17 +197,76 @@ const exercise = createDocumentResource({
const submission = createDocumentResource({
doctype: 'LMS Programming Exercise Submission',
name: props.submissionID,
cache: ['programmingExerciseSubmission', props.submissionID],
auto: false,
onError(error: any) {
if (error.messages?.[0].includes('not found')) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
} else {
toast.error(__(error.messages?.[0] || error))
}
},
})
watch(exercise, () => {
updateCode()
})
const updateCode = (submissionCode = '') => {
updateBoilerPlate()
if (!code.value?.includes(boilerplate.value)) {
code.value = `${boilerplate.value}${code.value}`
}
if (submissionCode && !code.value?.includes(submissionCode)) {
code.value = `${code.value}${submissionCode}`
} else if (!submissionCode && !code.value) {
code.value = boilerplate.value
}
}
const updateBoilerPlate = () => {
if (exercise.doc?.language == 'Python') {
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
} else if (exercise.doc?.language == 'JavaScript') {
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
}
}
const checkIfUserIsPermitted = (doc: any = null) => {
if (!user.data) {
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
}
if (!doc) return
if (
doc.owner != user.data?.name &&
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data.is_evaluator
) {
router.push({
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: 'new' },
})
return
}
}
const updateTestCases = (doc: any) => {
if (testCases.value.length === 0) {
testCases.value = doc.test_cases || []
}
}
watch(
() => submission.doc,
(doc) => {
if (doc) {
code.value = `${boilerplate.value}${doc.code || ''}\n`
if (testCases.value.length === 0) {
testCases.value = doc.test_cases || []
}
checkIfUserIsPermitted(doc)
updateTestCases(doc)
updateCode(doc.code)
}
},
{ immediate: true }
@@ -191,7 +275,7 @@ watch(
const loadFalcon = () => {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = 'https://falcon.frappe.io/static/livecode.js'
script.src = `${falconURL}static/livecode.js`
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
@@ -246,10 +330,11 @@ const createSubmission = () => {
name: 'ProgrammingExerciseSubmission',
params: { exerciseID: props.exerciseID, submissionID: data },
})
fetchSubmission(data)
} else {
submission.reload()
fetchSubmission(props.submissionID)
}
toast.success(__('Submitted successfully!'))
toast.success(__('Submission saved!'))
})
.catch((error: any) => {
console.error('Error creating submission:', error)
@@ -267,7 +352,7 @@ const execute = (stdin = ''): Promise<string> => {
let session = new LiveCodeSession({
base_url: 'https://falcon.frappe.io',
runtime: 'python',
runtime: exercise.doc?.language.toLowerCase() || 'python',
code: code.value,
files: [{ filename: 'stdin', contents: stdin }],
onMessage: (msg: any) => {

View File

@@ -26,6 +26,7 @@
doctype="User"
v-model="filters.member"
:placeholder="__('Filter by Member')"
:readonly="isStudent"
/>
<FormControl
v-model="filters.status"
@@ -45,7 +46,7 @@
:rows="submissions.data"
rowKey="name"
:options="{
selectable: false,
selectable: true,
}"
>
<ListHeader
@@ -100,6 +101,18 @@
</ListRow>
</router-link>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteExercises(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Programming Exercise Submissions" />
<div
@@ -127,7 +140,9 @@ import {
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
usePageMeta,
toast,
} from 'frappe-ui'
import type {
ProgrammingExerciseSubmission,
@@ -136,6 +151,7 @@ import type {
import { computed, inject, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { Trash2 } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import EmptyState from '@/components/EmptyState.vue'
@@ -151,9 +167,11 @@ const filters = ref<Filters>({
const router = useRouter()
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator) {
router.push({ name: 'Courses' })
}
setFiltersFromRoute()
fetchBasedOnRole()
})
const setFiltersFromRoute = () => {
filterFields.forEach((field) => {
if (router.currentRoute.value.query[field]) {
filters.value[field as keyof Filters] = router.currentRoute.value.query[
@@ -161,7 +179,15 @@ onMounted(() => {
] as string
}
})
})
}
const fetchBasedOnRole = () => {
if (isStudent.value) {
filters.value['member'] = user.data?.name
} else {
submissions.reload()
}
}
const submissions = createListResource({
doctype: 'LMS Programming Exercise Submission',
@@ -174,7 +200,7 @@ const submissions = createListResource({
'status',
'modified',
],
auto: true,
orderBy: 'modified desc',
transform(data: ProgrammingExercise[]) {
return data.map((submission: ProgrammingExerciseSubmission) => {
return {
@@ -214,6 +240,22 @@ watch(filters.value, () => {
submissions.reload()
})
const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
Array.from(selections).forEach(async (submission: string) => {
await submissions.delete.submit(submission)
})
unselectAll()
toast.success(__('Submissions deleted successfully'))
}
const isStudent = computed(() => {
return (
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data?.is_evaluator
)
})
const submissionColumns = computed(() => {
return [
{

View File

@@ -81,7 +81,7 @@
/>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, inject, onMounted, ref } from 'vue'
import {
Breadcrumbs,
Button,
@@ -91,6 +91,7 @@ import {
} from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
const exerciseCount = ref<number>(0)
@@ -98,11 +99,26 @@ const readOnlyMode = window.read_only_mode
const { brand } = sessionStore()
const showForm = ref<boolean>(false)
const exerciseID = ref<string | null>('new')
const user = inject<any>('$user')
const router = useRouter()
onMounted(() => {
validatePermissions()
getExerciseCount()
})
const validatePermissions = () => {
if (
!user.data?.is_instructor &&
!user.data?.is_moderator &&
!user.data?.is_evaluator
) {
router.push({
name: 'ProgrammingExerciseSubmissions',
})
}
}
const getExerciseCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Programming Exercise',

View File

@@ -19,4 +19,29 @@ type Filters = {
exercise?: string,
member?: string,
status?: string
}
type ProgrammingExercises = {
data: ProgrammingExercise[]
reload: () => void
hasNextPage: boolean
next: () => void
setValue: {
submit: (
data: ProgrammingExercise,
options?: { onSuccess?: () => void }
) => void
}
insert: {
submit: (
data: ProgrammingExercise,
options?: { onSuccess?: () => void }
) => void
}
delete: {
submit: (
name: string,
options?: { onSuccess?: () => void }
) => void
}
}

View File

@@ -439,6 +439,17 @@ export function getSidebarLinks() {
to: 'Batches',
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
},
{
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
activeFor: [
'ProgrammingExercises',
'ProgrammingExerciseForm',
'ProgrammingExerciseSubmissions',
'ProgrammingExerciseSubmission',
],
},
{
label: 'Certified Members',
icon: 'GraduationCap',