Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion frontend/src/components/DragDrop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,31 @@
:key="item.name"
class="rounded-xl border border-outline-gray-2 bg-surface-white p-4 text-ink-gray-9 shadow-sm"
>
<div class="flex flex-wrap items-center gap-2 leading-7">
<div v-if="getItemDisplayType(item) === 'Image'" class="space-y-4">
<div class="overflow-hidden rounded-2xl border border-outline-gray-2 bg-surface-gray-1">
<img
:src="item.image"
:alt="item.correct_answer"
class="h-56 w-full object-contain bg-surface-white sm:h-72"
/>
</div>
<div v-if="imagePromptText(item)" class="text-sm leading-6 text-ink-gray-7">
{{ imagePromptText(item) }}
</div>
<button
type="button"
class="min-h-14 w-full rounded-2xl border-2 px-4 py-3 text-center text-sm font-medium transition duration-150 ease-out"
:class="dropTargetClass(item.name)"
@click="placeSelected(item.name)"
@dragover.prevent
@dragenter.prevent="activeDropTarget = item.name"
@dragleave.prevent="clearDropTarget(item.name)"
@drop.prevent="dropAnswer(item.name)"
>
{{ placements[item.name]?.label || __('Drop here') }}
</button>
</div>
<div v-else class="flex flex-wrap items-center gap-2 leading-7">
<span>{{ item.prompt_before }}</span>
<button
type="button"
Expand Down Expand Up @@ -203,6 +227,10 @@ const activity = createResource({

const items = computed(() => activity.data?.items || [])

const getItemDisplayType = (item) => item.display_type || 'Text'

const imagePromptText = (item) => [item.prompt_before, item.prompt_after].filter(Boolean).join(' ')

const availableAnswers = computed(() =>
answerBank.value.filter(
(answer) => !Object.values(placements).some((placed) => placed?.id === answer.id)
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/MobileLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ watch(userResource, () => {
if (isModerator.value || isInstructor.value) {
addProgrammingExercises()
addQuizzes()
addDragDropActivities()
addAssignments()
}
}
Expand All @@ -168,6 +169,21 @@ const addQuizzes = () => {
})
}

const addDragDropActivities = () => {
otherLinks.value.push({
label: 'Drag & Drop Activities',
icon: 'Hand',
to: 'DragDropActivities',
activeFor: [
'DragDropActivities',
'DragDropForm',
'DragDropPage',
'DragDropSubmissionList',
'DragDropSubmission',
],
})
}

const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/pages/DragDrop/DragDropForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@
<div class="space-y-4">
<div v-for="(item, idx) in details.doc.items" :key="item.name || idx" class="rounded-lg border p-4 space-y-4">
<div class="grid grid-cols-2 gap-4">
<FormControl
v-model="item.display_type"
type="select"
:options="displayTypes"
:label="__('Display Type')"
/>
</div>
<div v-if="getItemDisplayType(item) === 'Text'" class="grid grid-cols-2 gap-4">
<FormControl v-model="item.prompt_before" :label="__('Prompt Before')" />
<FormControl v-model="item.prompt_after" :label="__('Prompt After')" />
</div>
<div v-else>
<Uploader
v-model="item.image"
:label="__('Prompt Image')"
:description="__('Upload the image learners should match with an answer.')"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<FormControl v-model="item.correct_answer" :label="__('Correct Answer')" :required="true" />
<FormControl type="number" v-model="item.marks" :label="__('Marks')" :required="true" />
Expand All @@ -68,6 +83,7 @@ import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { escapeHTML } from '@/utils'
import Uploader from '@/components/Controls/Uploader.vue'

const { brand } = sessionStore()
const user = inject('$user')
Expand All @@ -94,12 +110,18 @@ const details = createDocumentResource({
auto: false,
})

const displayTypes = ['Text', 'Image']

const getItemDisplayType = (item) => item.display_type || 'Text'

const addItem = () => {
if (!details.doc.items) {
details.doc.items = []
}
details.doc.items.push({
doctype: 'LMS Drag Drop Item',
display_type: 'Text',
image: '',
prompt_before: '',
prompt_after: '',
correct_answer: '',
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,21 @@ const getSidebarItems = () => {
'QuizSubmission',
],
},
{
label: 'Drag & Drop Activities',
icon: 'Hand',
to: 'DragDropActivities',
condition: () => {
return isAdmin()
},
activeFor: [
'DragDropActivities',
'DragDropForm',
'DragDropPage',
'DragDropSubmissionList',
'DragDropSubmission',
],
},
{
label: 'Assignments',
icon: 'Pencil',
Expand Down
11 changes: 9 additions & 2 deletions lms/lms/doctype/lms_drag_drop_activity/lms_drag_drop_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ def autoname(self):

def validate_items(self):
for row in self.items:
if not row.prompt_before and not row.prompt_after:
frappe.throw(_("Each row must contain prompt text before or after the blank."))
display_type = (row.display_type or "Text").strip().lower()

if display_type == "image":
if not row.image:
frappe.throw(_("Each image row must contain an image."))
elif not row.prompt_before and not row.prompt_after:
frappe.throw(_("Each text row must contain prompt text before or after the blank."))

if not row.correct_answer:
frappe.throw(_("Each row must have a correct answer."))
Expand Down Expand Up @@ -62,6 +67,8 @@ def submit_activity(activity: str, answers: str):
results.append(
{
"item": item.name,
"display_type": item.display_type,
"image": item.image,
"prompt_before": item.prompt_before,
"prompt_after": item.prompt_after,
"submitted_answer": submitted_answer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,63 @@ def test_total_marks_are_calculated(self):
)
activity.insert()
self.assertEqual(activity.total_marks, 5)

def test_image_item_is_valid_with_image(self):
activity = frappe.get_doc(
{
"doctype": "LMS Drag Drop Activity",
"title": "Image Activity",
"passing_percentage": 100,
"items": [
{
"doctype": "LMS Drag Drop Item",
"display_type": "Image",
"image": "/files/example.png",
"correct_answer": "Answer",
"marks": 2,
}
],
}
)
activity.insert()
self.assertEqual(activity.total_marks, 2)

def test_text_item_requires_prompt_text(self):
activity = frappe.get_doc(
{
"doctype": "LMS Drag Drop Activity",
"title": "Invalid Text Activity",
"passing_percentage": 100,
"items": [
{
"doctype": "LMS Drag Drop Item",
"display_type": "Text",
"correct_answer": "Answer",
"marks": 1,
}
],
}
)

with self.assertRaises(frappe.ValidationError):
activity.insert()

def test_image_item_requires_image(self):
activity = frappe.get_doc(
{
"doctype": "LMS Drag Drop Activity",
"title": "Invalid Image Activity",
"passing_percentage": 100,
"items": [
{
"doctype": "LMS Drag Drop Item",
"display_type": "Image",
"correct_answer": "Answer",
"marks": 1,
}
],
}
)

with self.assertRaises(frappe.ValidationError):
activity.insert()
15 changes: 15 additions & 0 deletions lms/lms/doctype/lms_drag_drop_item/lms_drag_drop_item.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,27 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"display_type",
"image",
"prompt_before",
"prompt_after",
"correct_answer",
"marks"
],
"fields": [
{
"default": "Text",
"fieldname": "display_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Display Type",
"options": "Text\nImage"
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image"
},
{
"fieldname": "prompt_before",
"fieldtype": "Small Text",
Expand Down
14 changes: 14 additions & 0 deletions lms/lms/doctype/lms_drag_drop_result/lms_drag_drop_result.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"engine": "InnoDB",
"field_order": [
"item",
"display_type",
"image",
"prompt_before",
"prompt_after",
"submitted_answer",
Expand All @@ -21,6 +23,18 @@
"fieldtype": "Data",
"label": "Item"
},
{
"fieldname": "display_type",
"fieldtype": "Data",
"label": "Display Type",
"read_only": 1
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image",
"read_only": 1
},
{
"fieldname": "prompt_before",
"fieldtype": "Small Text",
Expand Down
2 changes: 2 additions & 0 deletions memory-bank/activeContext.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
- 2026-03-20: Confirmed this app runs inside the parent Docker Compose project at `/Users/purwaren/Projects/frappe/frappe-learning/docker-compose.yml` and frontend verification should target the `frappe` container path `/home/frappe/frappe-bench/apps/lms`.
- 2026-03-20: Updated GitHub workflows to prefer `github.token` over a custom release token for repo-local release, note regeneration, PR automation, translation PRs, and GHCR publishing; also standardized several checkout actions and improved UI test artifact capture.
- 2026-03-20: Removed the duplicate `Semantic Commits` job from `.github/workflows/linters.yml` after confirming workflow failures were caused by commitlint, not the separate PR-title validation workflow. Repo rule is now to enforce semantic PR titles, not every commit message.
- 2026-03-21: Extended drag and drop activities to support mixed item rendering modes. Each row can now render as either text (`prompt_before`/`prompt_after`) or image (`image` + drop target), while keeping the same answer-bank and scoring flow.

## Next Actions
- Continue frontend verification from the parent compose project using `docker compose exec frappe ...` instead of host-shell builds.
- Investigate why `yarn build` in the `frappe` container remains inside the Vite build phase for several minutes without completing.
- Watch the next GitHub Actions runs to confirm built-in token permissions are sufficient for release notes, weekly release PR creation, semantic release, and POT-file PR automation.
- Watch the next PR run to confirm `Validate PR title` remains the only semantic gate and that the removed commitlint check no longer blocks non-conventional commit messages.
- Manually verify mixed text/image drag and drop activities end-to-end in the LMS UI, including authoring, answer placement, submission, and retry flows.
- Use the memory bank as the starting context for the next implementation or review task in this repo.
- Expand system notes when future work touches under-documented areas like payments, search indexing, or SCORM delivery.

Expand Down
3 changes: 3 additions & 0 deletions memory-bank/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
- 2026-03-20: Verified the active local runtime is the parent Docker Compose project, with frontend commands expected to run through `docker compose exec frappe ...` against `/home/frappe/frappe-bench/apps/lms`.
- 2026-03-20: Updated GitHub Actions workflows to use the built-in `github.token` for GHCR publishing and repo-local automation, added explicit permissions to more workflows, removed the hardcoded Cypress record key, removed the CI-time Cypress install step, and uploaded UI artifacts on every run.
- 2026-03-20: Removed duplicate commitlint enforcement from `.github/workflows/linters.yml` so semantic PR titles remain the single semantic convention gate.
- 2026-03-21: Added drag and drop item display modes with new `display_type` and `image` fields, backend validation for text vs image rows, authoring UI support for image prompts, learner-side image placeholder rendering, and result-schema support for image submissions.

## In Progress
- Frontend verification for the drag and drop refresh is still in progress because the containerized `yarn build` does not complete after entering the Vite transform/build phase.
- Workflow cleanup still needs live GitHub run verification to confirm every release-related job works with built-in token permissions.
- Workflow cleanup still needs live PR verification to confirm the semantic gate now comes only from PR title validation.
- Drag and drop image-mode verification still needs a live app pass to confirm uploader behavior and mixed-mode rendering work correctly inside Frappe.

## Todo
- Keep the memory bank current as future feature work, fixes, and design decisions land.
- Add deeper historical notes when specific subsystems are modified or reviewed in detail.
- Diagnose why `docker compose exec frappe bash -lc 'cd /home/frappe/frappe-bench/apps/lms/frontend && yarn build'` hangs during `vite build`.
- Review whether the remaining PR semantic action should also be pinned or replaced with a more stable dependency reference.
- Run targeted app-level tests or manual QA for the new drag and drop image mode once the local/container runtime is ready.

## Risks
- The API and DocType surface area is large, so changes can have non-obvious cross-feature effects.
Expand Down
Loading