MOBILE-4457 question: Support ordering question type
parent
86477e69c5
commit
5cee8d4935
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"moved": "{{$a.item}} moved. New position: {{$a.position}} of {{$a.total}}."
|
||||
}
|
|
@ -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 {}
|
|
@ -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);
|
|
@ -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,
|
||||
|
|
|
@ -19,6 +19,10 @@
|
|||
margin: 0 0 .5em;
|
||||
}
|
||||
|
||||
p {
|
||||
--color: var(--core-question-feedback-color);
|
||||
}
|
||||
|
||||
.correctness {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue