Skip to content

Commit

Permalink
Merge branch 'qti-item'
Browse files Browse the repository at this point in the history
  • Loading branch information
Patrick de Klein committed Dec 20, 2024
1 parent 59ccdf3 commit db80599
Show file tree
Hide file tree
Showing 19 changed files with 498 additions and 349 deletions.
37 changes: 37 additions & 0 deletions public/assets/qti-item/example-choice-item.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-model href="https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0_v1p0.xsd" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<qti-assessment-item xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0_v1p0.xsd"
identifier="ITM-choice" title="Unattended Luggage" adaptive="false" time-dependent="false">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>ChoiceA</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
<qti-default-value>
<qti-value>0</qti-value>
</qti-default-value>
</qti-outcome-declaration>

<qti-outcome-declaration identifier="MAXSCORE" cardinality="single" base-type="float">
<qti-default-value>
<qti-value>1</qti-value>
</qti-default-value>
</qti-outcome-declaration>
<qti-item-body>
<p>Look at the text in the picture.</p>
<p>
<img src="./img/sign.png" alt="NEVER LEAVE LUGGAGE UNATTENDED" />
</p>
<qti-choice-interaction response-identifier="RESPONSE" shuffle="false" max-choices="1">
<qti-prompt>What does it say?</qti-prompt>
<qti-simple-choice identifier="ChoiceA">You must stay with your luggage at all times.</qti-simple-choice>
<qti-simple-choice identifier="ChoiceB">Do not let someone else look after your luggage.</qti-simple-choice>
<qti-simple-choice identifier="ChoiceC">Remember your luggage when you leave.</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing
template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct.xml" />
</qti-assessment-item>
Binary file added public/assets/qti-item/img/sign.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/lib/qti-item/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './item-container';
122 changes: 122 additions & 0 deletions src/lib/qti-item/components/item-container.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { ItemContainer } from '../components/item-container';
import { qtiTransformItem } from '../../qti-transformers';
import { expect } from '@storybook/test';
import { within } from 'shadow-dom-testing-library';
import { getWcStorybookHelpers } from 'wc-storybook-helpers';

const { events, args, argTypes, template } = getWcStorybookHelpers('item-container');

type Story = StoryObj<ItemContainer & typeof args>;

const meta: Meta<typeof ItemContainer & { 'item-url': string }> = {
component: 'item-container',
args: { ...args, 'item-url': '/qti-item/example-choice-item.xml' },
argTypes,
parameters: {
actions: {
handles: events
}
},
tags: ['autodocs', 'new']
};
export default meta;

export const ItemURL: Story = {
render: args => {
return html`<qti-item>${template(args)}</qti-item>`;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const assessmentItem = await canvas.findByShadowTitle('Unattended Luggage');
expect(assessmentItem).toBeInTheDocument();
}
};

export const ItemDoc: Story = {
render: (_, { loaded: { itemDoc } }) => {
return html`
<qti-item>
<item-container .itemDoc=${itemDoc}></item-container>
</qti-item>
`;
},
loaders: [
async ({ args }) => {
console.log('args', args);
const itemDoc = qtiTransformItem()
.load(args['item-url'])
.then(api => api.htmlDoc());
return { itemDoc };
}
],
play: ItemURL.play,
tags: ['!autodocs']
};

export const ItemXML: Story = {
render: (_, { loaded: { itemXML } }) => {
return html`
<qti-item>
<item-container .itemXML=${itemXML}></item-container>
</qti-item>
`;
},
loaders: [
async ({ args }) => {
const itemXML = await qtiTransformItem()
.load(args['item-url'])
.then(api => api.xml());
return { itemXML };
}
],
play: ItemURL.play,
tags: ['!autodocs']
};

export const ItemWithTemplate: Story = {
render: args => {
return html`
<qti-item>
<item-container item-url=${args['item-url']}>
<template>
<style>
qti-simple-choice {
border: 2px solid blue;
}
</style>
</template>
</item-container>
</qti-item>
`;
},
play: ItemURL.play,
tags: ['!autodocs']
};

export const ItemWithTemplateScale: Story = {
render: args => {
return html`
<qti-item>
<item-container item-url=${args['item-url']}>
<template>
<style>
qti-assessment-item {
padding: 1rem;
display: block;
aspect-ratio: 4 / 3;
width: 800px;
border: 2px solid blue;
transform: scale(0.5);
transform-origin: top left;
}
</style>
</template>
</item-container>
</qti-item>
`;
},
play: ItemURL.play,
tags: ['!autodocs']
};
96 changes: 96 additions & 0 deletions src/lib/qti-item/components/item-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { watch } from '../../decorators/watch';
import itemCss from '../../../item.css?inline';
import { qtiTransformItem } from '../../qti-transformers';

/**
* `<item-container>` is a custom element designed for hosting the qti-assessment-item.
* The `qti-assessment-item` will be placed inside the shadow DOM of this element.
* The element loads the item from the provided URL and renders it inside the shadow DOM.
*
* ### Styling
* Add a class to the element for styling.
*
* ```html
* <qti-item>
* <item-container class="m-4 bg-white" item-url="./path/to/item.xml"></item-container>
* </qti-item>
* ```
*/
@customElement('item-container')
export class ItemContainer extends LitElement {
/** URL of the item to load */
@property({ type: String, attribute: 'item-url' })
itemURL: string = null;

/** A parsed HTML document */
@state()
itemDoc: DocumentFragment = null;

/** The raw XML string */
@state()
itemXML: string = null;

/** Template content if provided */
private templateContent = null;

@watch('itemURL', { waitUntilFirstUpdate: true })
protected async handleItemURLChange() {
if (!this.itemURL) return;
try {
const api = await qtiTransformItem().load(this.itemURL);
this.itemDoc = api.htmlDoc();
} catch (error) {
console.error('Error loading or parsing XML:', error);
}
}

@watch('itemXML', { waitUntilFirstUpdate: true })
protected handleItemXMLChange() {
if (!this.itemXML) return;
try {
this.itemDoc = qtiTransformItem().parse(this.itemXML).htmlDoc();
} catch (error) {
console.error('Error parsing XML:', error);
}
}

async connectedCallback(): Promise<void> {
super.connectedCallback();
this.initializeTemplateContent();
this.applyStyles();
if (this.itemURL) {
this.handleItemURLChange();
}
if (this.itemXML) {
this.handleItemXMLChange();
}
}

private initializeTemplateContent() {
const template = this.querySelector('template') as HTMLTemplateElement;
this.templateContent = template ? template.content : html``;
}

private applyStyles() {
const sheet = new CSSStyleSheet();
sheet.replaceSync(itemCss);
this.shadowRoot.adoptedStyleSheets = [sheet];
}

render() {
return html`
${this.templateContent}
<slot></slot>
${until(this.itemDoc, html`<span>Loading...</span>`)}
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'item-container': ItemContainer;
}
}
3 changes: 1 addition & 2 deletions src/lib/qti-item/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from './qti-item';
export * from './qti-item.mixin';

export * from './components';
47 changes: 0 additions & 47 deletions src/lib/qti-item/qti-item.mixin.ts

This file was deleted.

28 changes: 28 additions & 0 deletions src/lib/qti-item/qti-item.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';
import { getWcStorybookHelpers } from 'wc-storybook-helpers';
import { QtiItem } from './qti-item';

const { events, args, argTypes, template } = getWcStorybookHelpers('qti-item');

type Story = StoryObj<QtiItem & typeof args>;

const meta: Meta<typeof QtiItem> = {
component: 'qti-item',
subcomponents: { ItemContainer: 'item-container' },
args,
argTypes,
parameters: {
actions: {
handles: events
}
},
tags: ['autodocs', 'new']
};
export default meta;

export const Default: Story = {
render: args => {
return html`${template(args, html`<item-container item-url="/qti-item/example-choice-item.xml"></item-container>`)}`;
}
};
26 changes: 16 additions & 10 deletions src/lib/qti-item/qti-item.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { LitElement } from 'lit';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { QtiItemMixin } from './qti-item.mixin';
import itemCss from '../../item.css?inline';

/**
* `<qti-item>` is a custom element designed for rendering a single `qti-assessment-item`.
* It can also host some functionalities to interact with the item like scoring, showing feedback, etc.
* Placing a mandatory `<item-container>` inside '<qti-item>' will load or parse the item and render it.
* See `<item-container>` for more details.
*
* ```html
* <qti-item>
* <item-container class="m-4 bg-white" item-url="./path/to/item.xml"></item-container>
* </qti-item>
* ```
*/
@customElement('qti-item')
export class QtiItem extends QtiItemMixin(LitElement) {
connectedCallback() {
super.connectedCallback();
// Dynamically create and apply styles
const sheet = new CSSStyleSheet();
sheet.replaceSync(itemCss);
this.shadowRoot.adoptedStyleSheets = [sheet];
export class QtiItem extends LitElement {
render() {
return html`<slot></slot>`;
}
}

Expand Down
Loading

0 comments on commit db80599

Please sign in to comment.