feat: Initial Scaffold for QTI editor components and add a development page#5963
feat: Initial Scaffold for QTI editor components and add a development page#5963Abhishek-Punhani wants to merge 3 commits into
Conversation
|
👋 Hi @Abhishek-Punhani, thanks for contributing! For the review process to begin, please verify that the following is satisfied:
Also check that issue requirements are satisfied & you ran Pull requests that don't follow the guidelines will be closed. Reviewer assignment can take up to 2 weeks. |
|
📢✨ Before we assign a reviewer, we'll turn on |
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean, well-scoped scaffold — the Composition API architecture, immutable update pattern, and i18n discipline all land well.
CI passing. Screenshot shows all three hardcoded cards rendering with working up/down/delete/add controls. All acceptance criteria met.
- suggestion (3):
canEditprop is inert with no keyboard path to open cards;QTIItemEditormissingemitsdeclaration (inconsistent withQTIEditor);transition-groupanimation CSS missing - nitpick (1):
setTimeoutvsnextTickinaddItem
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Reviewed the pull request diff checking for:
- Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
- Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
- Architecture: duplicated concerns, minimal interfaces, composition over inheritance
- Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
- Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
- Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
- Checked CI status and linked issue acceptance criteria
- For UI changes: inspected screenshots for layout, visual completeness, and consistency
| :displayMenu="true" | ||
| :canMoveUp="canMoveUp" | ||
| :canMoveDown="canMoveDown" | ||
| :canEdit="!isOpen" |
There was a problem hiding this comment.
suggestion: :canEdit="!isOpen" is silently inert. AssessmentItemToolbar only uses the canEdit prop to gate the EDIT_ITEM action, but EDIT_ITEM is not included in either iconActionsConfig or menuActionsConfig here. The prop has no runtime effect.
The larger consequence: there is no keyboard-accessible way to open a closed card. The only open path is @click.native="onCardClick" on KPageContainer (a non-focusable <div>), which doesn't respond to Enter/Space. Consider adding [AssessmentItemToolbarActions.EDIT_ITEM, { collapse: true }] to iconActionsConfig when the card is closed, and removing the now-dead :canEdit binding.
| }; | ||
|
|
||
| export default defineComponent({ | ||
| name: 'QTIItemEditor', |
There was a problem hiding this comment.
suggestion: This component emits open, close, and action but has no emits option declared. QTIEditor (the sibling) correctly has emits: ['update']. For consistency and to give tooling the event contract, add:
emits: ['open', 'close', 'action'],| </div> | ||
| </KPageContainer> | ||
|
|
||
| <transition-group |
There was a problem hiding this comment.
suggestion: transition-group name="list-complete" requires corresponding CSS classes (.list-complete-enter, .list-complete-leave-to, .list-complete-move, etc.) to produce any animation. None are defined in the scoped styles, so the transition silently does nothing. Either add the CSS or remove the name attribute until the animation is ready to implement.
| list.splice(pos, 0, newItem); | ||
| emit('update', list); | ||
| // Open the newly created card on the next tick | ||
| setTimeout(() => { |
There was a problem hiding this comment.
nitpick: setTimeout(() => { activeId.value = newItem.id; }, 0) works, but nextTick (imported from 'vue') is the idiomatic Vue way to defer until after the DOM update cycle and is more explicit about intent.
| activeId.value = null; | ||
| } | ||
|
|
||
| const cloneList = () => [...props.assessments]; |
There was a problem hiding this comment.
praise: The cloneList() + emit pattern throughout addItem, deleteItem, moveItemUp, moveItemDown is exactly right for the Vuex-independent prop-down/event-up contract — the parent always gets a fresh array and the component never mutates the prop directly.
| const NAMESPACE = 'QTIEditorStrings'; | ||
|
|
||
| const MESSAGES = { | ||
| noQuestionsPlaceholder: { |
There was a problem hiding this comment.
praise: Every string has both message and context, which is what translators need to produce accurate, in-context translations. Good discipline at the scaffolding stage.
AlexVelezLl
left a comment
There was a problem hiding this comment.
Thanks @Abhishek-Punhani! I've found some areas for improvement and a couple of things that weren't specified in the original issue 😅.
| TRASH: 'TRASH', | ||
| ADD_PREVIOUS_STEPS: 'ADD_PREVIOUS_STEPS', | ||
| ADD_NEXT_STEPS: 'ADD_NEXT_STEPS', | ||
| QTI_DEMO: 'QTI_DEMO', |
There was a problem hiding this comment.
Oh, let's just use a hardcoded string 😅, so that we don't forget to remove this constant later.
| > | ||
| <div | ||
| class="question-card-header" | ||
| :style="{ borderBottom: isOpen ? `1px solid ${$themePalette.grey.v_200}` : 'none' }" |
There was a problem hiding this comment.
Could we use $themeTokens.fineLine instead?
| <AssessmentItemToolbar | ||
| :iconActionsConfig="iconActionsConfig" | ||
| :menuActionsConfig="menuActionsConfig" | ||
| :displayMenu="true" | ||
| :canMoveUp="canMoveUp" | ||
| :canMoveDown="canMoveDown" | ||
| :canEdit="!isOpen" | ||
| :collapse="windowIsSmall" | ||
| :itemLabel="toolbarItemLabel" | ||
| analyticsLabel="QTI Question" | ||
| data-test="toolbar" | ||
| @click="action => $emit('action', action)" | ||
| /> |
There was a problem hiding this comment.
Ah, apologies, I didn't spec this. Could we create a new CollapsibleToolbar component in the components folder? Let's make this component more generic and reusable, and instead of having so many hardcoded "canMoveUp", "canMoveDown", etc. props, let's make this component receive a single array with the structure:
[{
"icon": "", (string or null)
"label": "", (required)
"handler": () => {},
"collapsed": true/false
}]So... collapsed will determine if this should go into the dropdown menu; the dropdown menu should only be visible if there is at least one item collapsed. For items that are always collapsed, "collapsed": true; if it depends, we can write the condition here, e.g., "collapsed": windowIsSmall.value. And we can filter out any actions that are not needed (e.g., the first item should not have move up).
|
|
||
| import { computed, defineComponent } from 'vue'; | ||
| import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; | ||
| import { useQTIStr } from '../../qtiEditorStrings'; |
There was a problem hiding this comment.
Could we use the same structure as the communityChannelsStrings file? Where the whole translator is exposed as a named export, and we use destructuring to get the values in the setup. e.g. here.
| import AssessmentItemToolbar from 'frontend/channelEdit/components/AssessmentItemToolbar'; | ||
| import { AssessmentItemToolbarActions } from 'frontend/channelEdit/constants'; |
There was a problem hiding this comment.
In general, we should always try to avoid importing from other modules outside the QTIEditor as much as possible.
| const { windowIsSmall } = useKResponsiveWindow(); | ||
|
|
||
| const containerStyle = computed(() => | ||
| windowIsSmall.value ? {} : { maxWidth: '85%', margin: '0 auto' }, |
There was a problem hiding this comment.
Some visual specs have changed (apologies 😅), now maxWidth should be a plain 1200px and the padding/margin should be 16px on small screens and 32px on other screens.
| activeId.value = null; | ||
| } | ||
|
|
||
| const cloneList = () => [...props.assessments]; |
There was a problem hiding this comment.
cloneList isn't fully descriptive, and we are not deep cloning the list; just wondering if it'd be clearer just to write [...props.assessments] where we need this.
| // Open the newly created card on the next tick | ||
| setTimeout(() => { | ||
| activeId.value = newItem.id; | ||
| }, 0); |
There was a problem hiding this comment.
We don't really need to wait until the next tick, right? we can just set the activeId and whenever the list is updated, the activeId will be set.
| if (activeId.value === item.id) closeItem(); | ||
| emit( | ||
| 'update', | ||
| cloneList().filter(i => i.id !== item.id), |
There was a problem hiding this comment.
Here we dont really need to clone the list because filter already returns a new list.
| function onItemAction(action, item, idx) { | ||
| switch (action) { | ||
| case AssessmentItemToolbarActions.EDIT_ITEM: | ||
| openItem(item.id); | ||
| break; | ||
| case AssessmentItemToolbarActions.DELETE_ITEM: | ||
| deleteItem(item); | ||
| break; | ||
| case AssessmentItemToolbarActions.ADD_ITEM_ABOVE: | ||
| addItem({ atIndex: idx }); | ||
| break; | ||
| case AssessmentItemToolbarActions.ADD_ITEM_BELOW: | ||
| addItem({ atIndex: idx + 1 }); | ||
| break; | ||
| case AssessmentItemToolbarActions.MOVE_ITEM_UP: | ||
| moveItemUp(idx); | ||
| break; | ||
| case AssessmentItemToolbarActions.MOVE_ITEM_DOWN: | ||
| moveItemDown(idx); | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
I think it may be easier for us to just use a toolbarActions slot on the QTIItemEditor and manage all these actions directly on the QTIEditor component, which is the component that actually handles the actions.
There was a problem hiding this comment.
Yeah great ! I have implemented that and have added a seperate file for actions config
…t page Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
…handlers Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
31a8d05 to
e85e2e7
Compare
|
@AlexVelezLl, currently, I am rendering the icons; for example, the move-up icon is not rendered. Should we add it in the disabled state, or is it fine as is? |
|
That'd be fine @Abhishek-Punhani, thanks! |
|
📢✨ Before we assign a reviewer, we'll turn on |
AlexVelezLl
left a comment
There was a problem hiding this comment.
Looking much better! Mostly comments about some team unwritten conventions 😅.
| const KIconButtonStub = { | ||
| name: 'KIconButton', | ||
| props: ['icon', 'tooltip', 'ariaLabel', 'disabled', 'color'], | ||
| template: ` | ||
| <button | ||
| :data-icon="icon" | ||
| :aria-label="ariaLabel" | ||
| :disabled="disabled" | ||
| @click="$emit('click')" | ||
| > | ||
| <slot name="menu" /> | ||
| </button> | ||
| `, | ||
| }; | ||
|
|
||
| const KDropdownMenuStub = { | ||
| name: 'KDropdownMenu', | ||
| props: ['options'], | ||
| template: `<div class="k-dropdown-menu"><slot /></div>`, | ||
| methods: { | ||
| // expose a helper so tests can directly trigger a select | ||
| selectOption(value) { | ||
| this.$emit('select', { value }); | ||
| }, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
I think we should be able to test this without stubs.
| @@ -0,0 +1,147 @@ | |||
| import { mount } from '@vue/test-utils'; | |||
There was a problem hiding this comment.
Oh, another little thing 😅. Now we prefer vue-testing-library for new frontend tests.
There was a problem hiding this comment.
😅, Yeah I will update the tests to use vue-testing-library
|
|
||
| import { computed, defineComponent } from 'vue'; | ||
|
|
||
| export default defineComponent({ |
There was a problem hiding this comment.
Lets use the export single object sintax instead (without defineComponent) export default {
| const dropdownOptions = computed(() => { | ||
| return collapsedMenuActions.value.map(action => ({ | ||
| label: action.label, | ||
| value: action.id, | ||
| disabled: action.disabled, | ||
| })); | ||
| }); | ||
|
|
||
| function handleSelect(option) { | ||
| const action = collapsedMenuActions.value.find(a => a.id === option.value); | ||
| if (action && action.handler) { | ||
| action.handler(); | ||
| } | ||
| } |
There was a problem hiding this comment.
We don't really need an ID for the action (at least not yet). If it is because of handleSelect, there is a better way: We can just pass the handler to the dropdownOptions array.
Then, on the handleSelect we can just do option.handler() :). So there is no need to have an id and do other iteration in the ' handleSelect '.
| optionsLabel: { | ||
| type: String, | ||
| default: 'Options', | ||
| }, |
There was a problem hiding this comment.
Let's default it to null, and let's use an optionsLabel$ directly on the template instead. I dont think we have an optionsLabel defined yet, so we can create a new one in the commonStrings module.
| /** 1-based position in the list */ | ||
| index: { | ||
| type: Number, | ||
| required: true, | ||
| }, |
There was a problem hiding this comment.
If we are calling it index, then we should probably just get the 0-based position and add 1 if we are displaying it to the user 😅. If we ever need the actual index position, it'll be awkward to subtract 1 for that.
| <transition-group | ||
| name="list-complete" | ||
| tag="div" | ||
| class="question-list" | ||
| > |
There was a problem hiding this comment.
Transition animations look a bit weird; I think we can just leave them out.
Grabacion.de.pantalla.2026-06-10.a.la.s.11.26.49.p.m.mov
| <script> | ||
|
|
||
| import { ref, computed, defineComponent } from 'vue'; | ||
| import { v4 as uuidv4 } from 'uuid'; |
There was a problem hiding this comment.
Could we copy this method here instead? This is the way IDs are generated in Studio right now. An alternative would be to accept an idGenerator as a prop, so that we can keep the logic for id generators external to the editor.
I think either way, we will probably need to expose the new and remove events anyway, because we will need this specific info to fit the change architecture in Studio, but I think we can live with this for now and figure out when we do the integration with Studio. For now, ensuring that the IDs generated here are the same as the IDs generated in Studio is enough :).
| noQuestionsPlaceholder: noQuestionsPlaceholder$(), | ||
| newQuestionBtnLabel: newQuestionBtnLabel$(), | ||
| showAnswersLabel: showAnswers$(), | ||
| containerStyle, | ||
| items, | ||
| activeId, | ||
| displayAnswersPreview, | ||
| closeItem, | ||
| addItem, | ||
| getToolbarActions, | ||
| optionsLabel: options$(), |
There was a problem hiding this comment.
Idem, let's just return the translator function instead.
Also, another undocumented "convention": we usually tend to leave the translator methods at the bottom of the return object, just to make it more readable 😅. Not super important for now (if it was, we should have a linter rule), but to let you know.
| if (idx > 0) { | ||
| result.push({ | ||
| id: 'move-up', | ||
| icon: 'chevronUp', | ||
| label: toolbarLabelMoveUp$(), | ||
| handler: () => moveItemUp(idx), | ||
| collapsed: windowIsSmall.value, | ||
| }); | ||
| } | ||
|
|
||
| if (idx < items.value.length - 1) { | ||
| result.push({ | ||
| id: 'move-down', | ||
| icon: 'chevronDown', | ||
| label: toolbarLabelMoveDown$(), | ||
| handler: () => moveItemDown(idx), | ||
| collapsed: windowIsSmall.value, | ||
| }); | ||
| } |
rtibblesbot
left a comment
There was a problem hiding this comment.
All four prior findings resolved. No new findings from me — @AlexVelezLl's latest review already covers the open items comprehensively.
CI passing. Screenshot in PR description appears to predate the CollapsibleToolbar introduction, but the rendering logic is straightforward to verify from the code.
Prior findings
Resolved:
:canEdit="!isOpen"silently inert / no keyboard path (suggestion) — replaced wholesale withuseQTIEditorActions+ slot-basedCollapsibleToolbar; edit button is now a keyboard-accessibleKIconButton- Missing
emitsdeclaration inQTIItemEditor(suggestion) — now hasemits: ['close'] transition-group name="list-complete"had no corresponding CSS (suggestion) — animation classes added inQTIEditor/index.vuesetTimeoutinaddItem(nitpick) — removed; direct assignment now
4/4 prior findings resolved. 0 re-raised.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
| a => | ||
| typeof a.id === 'string' && | ||
| typeof a.label === 'string' && | ||
| typeof a.handler === 'function', |
There was a problem hiding this comment.
praise: The prop validator enforces the action interface contract at the component boundary — any caller that omits id, label, or handler gets an immediate Vue warning rather than a silent runtime failure deeper in handleSelect. Good defensive practice for a component that will be reused.
| </h3> | ||
|
|
||
| <div class="question-card-actions toolbar"> | ||
| <slot name="toolbarActions"></slot> |
There was a problem hiding this comment.
praise: The toolbarActions slot cleanly decouples the card shell from the action model — QTIItemEditor has no knowledge of what actions exist or how they're triggered, and QTIEditor (the right place) owns all list-mutation logic. This is exactly the right boundary for a component that will accumulate editing features over time.

Summary
This PR introduces the initial frontend scaffolding for a brand-new QTI 3.0 authoring editor.
References
Closes #5961
Reviewer guidance
/#/qti-demo.AI usage
Used Antigravity for final review and nitpicks.