mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
307 lines
6.6 KiB
Vue
307 lines
6.6 KiB
Vue
<template>
|
|
<Dialog v-model="show" :options="{ size: '4xl' }">
|
|
<template #body-title>
|
|
<div class="flex items-center space-x-2">
|
|
<div class="text-xl font-semibold text-ink-gray-9">
|
|
{{
|
|
props.exerciseID === 'new'
|
|
? __('Create Programming Exercise')
|
|
: __('Edit Programming Exercise')
|
|
}}
|
|
</div>
|
|
<Badge v-if="isDirty" theme="orange">
|
|
{{ __('Not Saved') }}
|
|
</Badge>
|
|
</div>
|
|
</template>
|
|
<template #body-content>
|
|
<div class="grid grid-cols-2 gap-10">
|
|
<div class="space-y-4">
|
|
<FormControl
|
|
v-model="exercise.title"
|
|
:label="__('Title')"
|
|
:required="true"
|
|
/>
|
|
<FormControl
|
|
v-model="exercise.language"
|
|
:label="__('Language')"
|
|
type="select"
|
|
:options="languageOptions"
|
|
:required="true"
|
|
/>
|
|
<ChildTable
|
|
v-model="testCases.data"
|
|
:label="__('Test Cases')"
|
|
:columns="testCaseColumns"
|
|
:required="true"
|
|
:addable="true"
|
|
:deletable="true"
|
|
:editable="true"
|
|
:placeholder="__('Add Test Case')"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div>
|
|
<div class="text-xs text-ink-gray-5 mb-2">
|
|
{{ __('Problem Statement') }}
|
|
<span class="text-ink-red-3">*</span>
|
|
</div>
|
|
<TextEditor
|
|
:content="exercise.problem_statement"
|
|
@change="(val: string) => (exercise.problem_statement = val)"
|
|
:editable="true"
|
|
:fixedMenu="true"
|
|
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[21rem] overflow-y-auto"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #actions="{ close }">
|
|
<div class="flex justify-end space-x-2 group">
|
|
<Button
|
|
v-if="exerciseID != 'new'"
|
|
@click="deleteExercise(close)"
|
|
variant="outline"
|
|
theme="red"
|
|
>
|
|
<template #prefix>
|
|
<Trash2 class="size-4 stroke-1.5" />
|
|
</template>
|
|
{{ __('Delete') }}
|
|
</Button>
|
|
<router-link
|
|
v-if="exerciseID != 'new'"
|
|
:to="{
|
|
name: 'ProgrammingExerciseSubmission',
|
|
params: {
|
|
exerciseID: props.exerciseID,
|
|
submissionID: 'new',
|
|
},
|
|
}"
|
|
>
|
|
<Button>
|
|
<template #prefix>
|
|
<Play class="size-4 stroke-1.5" />
|
|
</template>
|
|
{{ __('Test this Exercise') }}
|
|
</Button>
|
|
</router-link>
|
|
<router-link
|
|
v-if="exerciseID != 'new'"
|
|
:to="{
|
|
name: 'ProgrammingExerciseSubmissions',
|
|
query: {
|
|
exercise: props.exerciseID,
|
|
},
|
|
}"
|
|
>
|
|
<Button>
|
|
<template #prefix>
|
|
<ClipboardList class="size-4 stroke-1.5" />
|
|
</template>
|
|
{{ __('Check Submission') }}
|
|
</Button>
|
|
</router-link>
|
|
<Button variant="solid" @click="saveExercise(close)">
|
|
{{ __('Save') }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { sanitizeHTML } from '@/utils'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
createListResource,
|
|
Dialog,
|
|
FormControl,
|
|
TextEditor,
|
|
toast,
|
|
} from 'frappe-ui'
|
|
import {
|
|
ProgrammingExercise,
|
|
ProgrammingExercises,
|
|
TestCase,
|
|
} from '@/types/programming-exercise'
|
|
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
|
import ChildTable from '@/components/Controls/ChildTable.vue'
|
|
|
|
const show = defineModel()
|
|
const exercises = defineModel<ProgrammingExercises>('exercises')
|
|
const isDirty = ref(false)
|
|
const originalTestCaseCount = ref(0)
|
|
|
|
const exercise = ref<ProgrammingExercise>({
|
|
title: '',
|
|
language: 'Python',
|
|
problem_statement: '',
|
|
test_cases: [],
|
|
})
|
|
|
|
const languageOptions = [
|
|
{ label: 'Python', value: 'Python' },
|
|
{ label: 'JavaScript', value: 'JavaScript' },
|
|
]
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
exerciseID: string
|
|
getExerciseCount: () => Promise<number>
|
|
}>(),
|
|
{
|
|
exerciseID: 'new',
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => props.exerciseID,
|
|
() => {
|
|
setExerciseData()
|
|
fetchTestCases()
|
|
}
|
|
)
|
|
|
|
const setExerciseData = () => {
|
|
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: [],
|
|
}
|
|
}
|
|
isDirty.value = false
|
|
}
|
|
|
|
const testCases = createListResource({
|
|
doctype: 'LMS Test Case',
|
|
fields: ['input', 'expected_output', 'name'],
|
|
parent: 'LMS Programming Exercise',
|
|
orderBy: 'idx',
|
|
onSuccess(data: TestCase[]) {
|
|
isDirty.value = false
|
|
originalTestCaseCount.value = data.length
|
|
},
|
|
onError(err: any) {
|
|
toast.error(__(err.messages?.[0] || err))
|
|
console.error('Error loading testCases:', err)
|
|
},
|
|
})
|
|
|
|
const fetchTestCases = () => {
|
|
testCases.update({
|
|
filters: {
|
|
parent: props.exerciseID,
|
|
parenttype: 'LMS Programming Exercise',
|
|
parentfield: 'test_cases',
|
|
},
|
|
})
|
|
testCases.reload()
|
|
originalTestCaseCount.value = testCases.data?.length
|
|
}
|
|
|
|
const validateTitle = () => {
|
|
exercise.value.title = sanitizeHTML(exercise.value.title.trim())
|
|
}
|
|
|
|
watch(
|
|
exercise,
|
|
() => {
|
|
isDirty.value = true
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
watch(testCases, () => {
|
|
if (testCases.data?.length !== originalTestCaseCount.value) {
|
|
isDirty.value = true
|
|
}
|
|
})
|
|
|
|
const updateTestCasesInExercise = () => {
|
|
exercise.value.test_cases = testCases.data.map(
|
|
(tc: TestCase, index: number) => ({
|
|
input: tc.input,
|
|
expected_output: tc.expected_output,
|
|
idx: index + 1,
|
|
})
|
|
)
|
|
}
|
|
|
|
const saveExercise = (close: () => void) => {
|
|
validateTitle()
|
|
updateTestCasesInExercise()
|
|
if (props.exerciseID == 'new') createNewExercise(close)
|
|
else updateExercise(close)
|
|
}
|
|
|
|
const createNewExercise = (close: () => void) => {
|
|
exercises.value?.insert.submit(
|
|
{
|
|
...exercise.value,
|
|
},
|
|
{
|
|
onSuccess() {
|
|
close()
|
|
isDirty.value = false
|
|
exercises.value?.reload()
|
|
props.getExerciseCount()
|
|
toast.success(__('Programming Exercise created successfully'))
|
|
},
|
|
onError(err: any) {
|
|
toast.warning(__(err.messages?.[0] || err))
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
const updateExercise = (close: () => void) => {
|
|
exercises.value?.setValue.submit(
|
|
{
|
|
name: props.exerciseID,
|
|
...exercise.value,
|
|
},
|
|
{
|
|
onSuccess() {
|
|
close()
|
|
isDirty.value = false
|
|
exercises.value?.reload()
|
|
toast.success(__('Programming Exercise updated successfully'))
|
|
},
|
|
onError(err: any) {
|
|
toast.warning(__(err.messages?.[0] || err))
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
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>
|