MOBILE-4457 question: Support ordering question type

main
Dani Palou 2024-04-15 15:13:30 +02:00
parent 86477e69c5
commit 5cee8d4935
11 changed files with 471 additions and 2 deletions

View File

@ -1147,6 +1147,7 @@
"addon.privatefiles.sitefiles": "moodle",
"addon.qtype_essay.maxwordlimitboundary": "qtype_essay",
"addon.qtype_essay.minwordlimitboundary": "qtype_essay",
"addon.qtype_ordering.moved": "qtype_ordering",
"addon.report_insights.actionsaved": "report_insights",
"addon.report_insights.fixedack": "analytics",
"addon.report_insights.incorrectlyflagged": "analytics",
@ -2247,6 +2248,8 @@
"core.mod_workshop": "workshop/pluginname",
"core.moduleintro": "moodle",
"core.more": "moodle/moremenu",
"core.movedown": "moodle",
"core.moveup": "moodle",
"core.mygroups": "group",
"core.name": "moodle",
"core.needhelp": "local_moodlemobileapp",

View File

@ -0,0 +1,55 @@
@if (question && (question.text || question.text === '')) {
<div>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.text" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="courseId" />
</ion-label>
</ion-item>
@if (a11yAnnouncement) {
<div aria-live="polite" class="sr-only">{{ a11yAnnouncement }}</div>
}
<ion-reorder-group class="{{numberingClass}}" [disabled]="dragDisabled" (ionItemReorder)="moveItem($event.detail)">
@for (item of question.items; track item.id) {
<ion-card id="{{item.id}}" class="core-question-answer-{{item.correctClass}}">
<ion-item class="ion-text-wrap">
<ion-reorder slot="start" aria-hidden="true" />
@if (dragDisabled) {
@if (item.correctClass === 'correct') {
<ion-icon name="fas-check" slot="start" />
} @else if (item.correctClass === 'incorrect') {
<ion-icon name="fas-xmark" slot="start" />
} @else if (item.correctClass.startsWith('partial')) {
<ion-icon name="far-square-check" slot="start" />
}
}
<ion-label id="{{item.id}}-text">
<core-format-text [component]="component" [componentId]="componentId" [text]="item.content"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" />
</ion-label>
@if (!dragDisabled) {
<div slot="end" class="flex-row">
<ion-button fill="clear" (click)="moveItemByClick($event, false, item.id)" data-action="move-backward"
[attr.aria-label]="'core.moveup' | translate" [attr.aria-description]="item.contentText">
<ion-icon slot="icon-only" name="fas-chevron-up" aria-hidden="true" />
</ion-button>
<ion-button fill="clear" (click)="moveItemByClick($event, true, item.id)" data-action="move-forward"
[attr.aria-label]="'core.movedown' | translate" [attr.aria-description]="item.contentText">
<ion-icon slot="icon-only" name="fas-chevron-down" aria-hidden="true" />
</ion-button>
</div>
}
</ion-item>
</ion-card>
}
</ion-reorder-group>
<!-- Create a hidden input to hold the value. -->
<input type="hidden" [ngModel]="responseInput.value" [attr.name]="responseInput.name">
</div>
}

View File

@ -0,0 +1,81 @@
@use "theme/globals" as *;
:host {
ion-reorder-group .item {
ion-label {
display: list-item;
list-style-position: inside;
list-style-type: none;
}
.core-correct-icon {
@include margin-horizontal(0px, 8px);
vertical-align: middle;
}
}
ion-reorder-group.numbering123 .item ion-label {
list-style-type: decimal;
}
ion-reorder-group.numberingabc .item ion-label {
list-style-type: lower-alpha;
}
ion-reorder-group.numberingABCD .item ion-label {
list-style-type: upper-alpha;
}
ion-reorder-group.numberingiii .item ion-label {
list-style-type: lower-roman;
}
ion-reorder-group.numberingIIII .item ion-label {
list-style-type: upper-roman;
}
ion-reorder-group ion-card {
contain: layout paint; // This is needed to make list-style to work properly (otherwise all items were 1/a/i).
&:first-of-type [data-action="move-backward"],
&:last-of-type [data-action="move-forward"] {
visibility: hidden;
}
&.core-question-answer-correct {
--border-color: var(--core-question-correct-color);
.item {
--color: var(--core-question-correct-color);
--background: var(--core-question-correct-color-bg);
}
}
&.core-question-answer-incorrect {
--border-color: var(--core-question-incorrect-color);
.item {
--color: var(--core-question-incorrect-color);
--background: var(--core-question-incorrect-color-bg);
}
}
&.core-question-answer-partial66 {
--border-color: #ff9900;
.item {
--color: var(--core-question-correct-color);
--background: var(--core-question-correct-color-bg);
}
}
&.core-question-answer-partial33 {
--border-color: #ff9900;
.item {
--color: var(--core-question-feedback-color);
--background: var(--core-question-feedback-color-bg);
}
}
&.core-question-answer-partial00 {
--border-color: #ff9900;
.item {
--color: var(--core-question-incorrect-color);
--background: var(--core-question-incorrect-color-bg);
}
}
}
}

View File

@ -0,0 +1,200 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ElementRef } from '@angular/core';
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreDomUtils } from '@services/utils/dom';
import { ItemReorderEventDetail } from '@ionic/angular';
import { Translate } from '@singletons';
import { CoreUtils } from '@services/utils/utils';
import { CorePlatform } from '@services/platform';
/**
* Component to render an ordering question.
*/
@Component({
selector: 'addon-qtype-ordering',
templateUrl: 'addon-qtype-ordering.html',
styleUrls: ['../../../../core/features/question/question.scss', 'ordering.scss'],
})
export class AddonQtypeOrderingComponent extends CoreQuestionBaseComponent<AddonQtypeOrderingQuestionData> {
dragDisabled = false;
numberingClass = '';
a11yAnnouncement = '';
responseInput = {
name: '',
value: '',
};
constructor(elementRef: ElementRef) {
super('AddonQtypeOrderingComponent', elementRef);
}
/**
* @inheritdoc
*/
init(): void {
if (!this.question) {
return;
}
const questionElement = this.initComponent();
if (!questionElement) {
return;
}
// Replace Moodle's feedback classes with our own.
CoreQuestionHelper.replaceFeedbackClasses(questionElement);
// Find the list and its items.
const listContainer = questionElement.querySelector('.sortablelist');
if (!listContainer) {
this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
return CoreQuestionHelper.showComponentError(this.onAbort);
}
this.dragDisabled = listContainer.classList.contains('notactive') || !listContainer.querySelector('.sortableitem');
this.numberingClass = Array.from(listContainer.classList).find(className => className.startsWith('numbering')) ?? '';
const itemsElements = Array.from(listContainer.querySelectorAll('li'));
this.question.items = itemsElements.map(element => {
// Remove correctness icons from the content.
const itemContentEl = element.querySelector<HTMLElement>('[data-itemcontent]');
itemContentEl?.querySelector('.icon.fa-check, .icon.fa-remove, .icon.fa-check-square')?.remove();
return {
id: element.id,
content: itemContentEl?.innerHTML ?? '',
contentText: itemContentEl?.innerText ?? '',
correctClass: Array.from(element.classList)
.find(className => className.includes('correct') || className.includes('partial')) ?? 'pending',
};
});
// Find the input where the answer is stored.
const inputEl = questionElement.querySelector<HTMLInputElement>('input[name*="_response_"]');
if (inputEl) {
this.responseInput.name = inputEl.name;
this.responseInput.value = inputEl.value;
}
// Re-calculate the text of the question, removing the elements that the app already renders.
questionElement.querySelector('.ablock')?.remove();
inputEl?.remove();
this.question.text = CoreDomUtils.getContentsOfElement(questionElement, '.qtext');
}
/**
* Reorder items list.
*
* @param eventDetail Details of the reorder.
*/
moveItem(eventDetail: ItemReorderEventDetail): void {
if (!this.question?.items) {
return;
}
const itemToMove = this.question.items.splice(eventDetail.from, 1)[0];
this.question.items.splice(eventDetail.to, 0, itemToMove);
this.responseInput.value = this.question.items.map(item => item.id).join(',');
this.a11yAnnouncement = Translate.instant('addon.qtype_ordering.moved', {
$a: {
item: this.hostElement.querySelector<HTMLElement>(`#${itemToMove.id}-text`)?.innerText ?? itemToMove.id,
position: eventDetail.to + 1,
total: this.question.items.length,
},
});
eventDetail.complete();
}
/**
* Move an item to the previous or next position.
*
* @param event Event.
* @param moveNext Whether to move to the next position or the previous position.
* @param itemId Item ID.
*/
async moveItemByClick(event: Event, moveNext: boolean, itemId: string): Promise<void> {
event.preventDefault();
event.stopPropagation();
const target = event.target as HTMLElement;
if (!target || !this.question?.items) {
return;
}
const initialPosition = this.question.items.findIndex(item => item.id === itemId);
const endPosition = moveNext ? initialPosition + 1 : initialPosition - 1;
if (endPosition < 0 || endPosition >= this.question.items.length) {
// Invalid position.
return;
}
this.moveItem({
from: initialPosition,
to: endPosition,
complete: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
});
await CoreUtils.nextTick();
// When moving an item to the first or last position, the button that was clicked will be hidden. In this case, we need to
// focus the other button. Otherwise, re-focus the same button since the focus is lost in some cases.
const movedCard = document.querySelector<HTMLElement>(`#${itemId}`);
let elementToFocus = target;
if (movedCard && !movedCard.previousElementSibling) {
elementToFocus = movedCard.querySelector<HTMLElement>('[data-action="move-forward"]') ?? target;
} else if (movedCard && !movedCard.nextElementSibling) {
elementToFocus = movedCard.querySelector<HTMLElement>('[data-action="move-backward"]') ?? target;
}
CoreDomUtils.focusElement(elementToFocus);
if (CorePlatform.isIOS()) {
// In iOS, when the focus is lost VoiceOver automatically focus the element in the same position where the focus was.
// If that happens, make sure the focus stays in the button we want to focus.
const reFocus = () => {
elementToFocus.removeEventListener('blur', reFocus);
CoreDomUtils.focusElement(elementToFocus);
};
elementToFocus.addEventListener('blur', reFocus);
setTimeout(() => {
elementToFocus.removeEventListener('blur', reFocus);
}, 300);
}
}
}
/**
* Data for ordering question.
*/
export type AddonQtypeOrderingQuestionData = AddonModQuizQuestionBasicData & {
readOnly?: boolean;
items?: OrderingItem[];
};
type OrderingItem = {
id: string;
content: string;
contentText: string;
correctClass: string;
};

View File

@ -0,0 +1,3 @@
{
"moved": "{{$a.item}} moved. New position: {{$a.position}} of {{$a.total}}."
}

View File

@ -0,0 +1,41 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeOrderingHandler } from './services/handlers/ordering';
import { AddonQtypeOrderingComponent } from './component/ordering';
import { CoreSharedModule } from '@/core/shared.module';
@NgModule({
declarations: [
AddonQtypeOrderingComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useValue: () => {
CoreQuestionDelegate.registerHandler(AddonQtypeOrderingHandler.instance);
},
},
],
exports: [
AddonQtypeOrderingComponent,
],
})
export class AddonQtypeOrderingModule {}

View File

@ -0,0 +1,73 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable, Type } from '@angular/core';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { makeSingleton } from '@singletons';
import { CoreSites } from '@services/sites';
/**
* Handler to support ordering question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeOrderingHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeOrdering';
type = 'qtype_ordering';
/**
* @inheritdoc
*/
async getComponent(): Promise<Type<unknown>> {
const { AddonQtypeOrderingComponent } = await import('@addons/qtype/ordering/component/ordering');
return AddonQtypeOrderingComponent;
}
/**
* @inheritdoc
*/
isCompleteResponse(): number {
return 1;
}
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return !!CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.4');
}
/**
* @inheritdoc
*/
isGradableResponse(): number {
return 1;
}
/**
* @inheritdoc
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.compareAllAnswers(prevAnswers, newAnswers);
}
}
export const AddonQtypeOrderingHandler = makeSingleton(AddonQtypeOrderingHandlerService);

View File

@ -27,6 +27,7 @@ import { AddonQtypeMatchModule } from './match/match.module';
import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module';
import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module';
import { AddonQtypeNumericalModule } from './numerical/numerical.module';
import { AddonQtypeOrderingModule } from './ordering/ordering.module';
import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module';
import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module';
import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
@ -46,6 +47,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
AddonQtypeMultiAnswerModule,
AddonQtypeMultichoiceModule,
AddonQtypeNumericalModule,
AddonQtypeOrderingModule,
AddonQtypeRandomSaMatchModule,
AddonQtypeShortAnswerModule,
AddonQtypeTrueFalseModule,

View File

@ -19,6 +19,10 @@
margin: 0 0 .5em;
}
p {
--color: var(--core-question-feedback-color);
}
.correctness {
display: inline-block;
padding: 2px 4px;

View File

@ -204,6 +204,8 @@
"mod_workshop": "Workshop",
"moduleintro": "Description",
"more": "More",
"movedown": "Move down",
"moveup": "Move up",
"mygroups": "My groups",
"name": "Name",
"needhelp": "Need help?",

View File

@ -295,10 +295,11 @@ export class CoreDomUtilsProvider {
* @param element HTML element to focus.
*/
async focusElement(
element: HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSearchbarElement | HTMLElement,
element: HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSearchbarElement | HTMLIonButtonElement | HTMLElement,
): Promise<void> {
let retries = 10;
const isIonButton = element.tagName === 'ION-BUTTON';
let elementToFocus = element;
/**
@ -319,6 +320,10 @@ export class CoreDomUtilsProvider {
// If it's an Ionic element get the right input to use.
elementToFocus.componentOnReady && await elementToFocus.componentOnReady();
elementToFocus = await elementToFocus.getInputElement();
} else if (isIonButton) {
// For ion-button, we need to call focus on the inner button. But the activeElement will be the ion-button.
('componentOnReady' in elementToFocus) && await elementToFocus.componentOnReady();
elementToFocus = elementToFocus.shadowRoot?.querySelector('.button-native') ?? elementToFocus;
}
if (!elementToFocus || !elementToFocus.focus) {
@ -328,7 +333,7 @@ export class CoreDomUtilsProvider {
while (retries > 0 && elementToFocus !== document.activeElement) {
elementToFocus.focus();
if (elementToFocus === document.activeElement) {
if (elementToFocus === document.activeElement || (isIonButton && element === document.activeElement)) {
await CoreUtils.nextTick();
if (CorePlatform.isAndroid() && this.supportsInputKeyboard(elementToFocus)) {
// On some Android versions the keyboard doesn't open automatically.