Skip to content

feat: Initial Scaffold for QTI editor components and add a development page#5963

Open
Abhishek-Punhani wants to merge 1 commit into
learningequality:unstablefrom
Abhishek-Punhani:Issue5961
Open

feat: Initial Scaffold for QTI editor components and add a development page#5963
Abhishek-Punhani wants to merge 1 commit into
learningequality:unstablefrom
Abhishek-Punhani:Issue5961

Conversation

@Abhishek-Punhani

Copy link
Copy Markdown
Member

Summary

This PR introduces the initial frontend scaffolding for a brand-new QTI 3.0 authoring editor.

image

References

Closes #5961

Reviewer guidance

  1. Navigate to any channel in the Studio channel editor.
  2. Replace the end of the URL with /#/qti-demo.
  3. You will see the new standalone QTIEditor loaded with three hardcoded questions.

AI usage

Used Antigravity for final review and nitpicks.

…t page

Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @Abhishek-Punhani, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@AlexVelezLl AlexVelezLl requested a review from rtibblesbot June 9, 2026 19:49
@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): canEdit prop is inert with no keyboard path to open cards; QTIItemEditor missing emits declaration (inconsistent with QTIEditor); transition-group animation CSS missing
  • nitpick (1): setTimeout vs nextTick in addItem

@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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that'd be a nice addition.

</div>
</KPageContainer>

<transition-group

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AlexVelezLl self-requested a review June 9, 2026 20:04
@AlexVelezLl AlexVelezLl self-assigned this Jun 9, 2026

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use $themeTokens.fineLine instead?

Comment on lines +28 to +40
<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)"
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +75 to +76
import AssessmentItemToolbar from 'frontend/channelEdit/components/AssessmentItemToolbar';
import { AssessmentItemToolbarActions } from 'frontend/channelEdit/constants';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +114 to +117
// Open the newly created card on the next tick
setTimeout(() => {
activeId.value = newItem.id;
}, 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we dont really need to clone the list because filter already returns a new list.

Comment on lines +142 to +163
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;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Implement initial Scaffold for the new QTI editor component

3 participants