From db80599904915a4447384a543d460cf2dcbdb88c Mon Sep 17 00:00:00 2001 From: Patrick de Klein Date: Fri, 20 Dec 2024 09:38:54 +0100 Subject: [PATCH] Merge branch 'qti-item' --- .../assets/qti-item/example-choice-item.xml | 37 ++++++ public/assets/qti-item/img/sign.png | Bin 0 -> 4029 bytes src/lib/qti-item/components/index.ts | 1 + .../components/item-container.stories.ts | 122 ++++++++++++++++++ src/lib/qti-item/components/item-container.ts | 96 ++++++++++++++ src/lib/qti-item/index.ts | 3 +- src/lib/qti-item/qti-item.mixin.ts | 47 ------- src/lib/qti-item/qti-item.stories.ts | 28 ++++ src/lib/qti-item/qti-item.ts | 26 ++-- .../components/test-container.stories.ts | 23 ++-- src/lib/qti-test/components/test-container.ts | 121 +++++++++-------- src/lib/qti-test/mixins/test-loader.mixin.ts | 34 ++--- src/lib/qti-test/qti-test-conformance.spec.ts | 18 --- .../qti-test/qti-test-conformance.stories.ts | 103 --------------- src/lib/qti-test/qti-test.stories.ts | 65 ++++++++++ src/lib/qti-test/qti-test.ts | 56 ++++---- .../qti-transformers/qti-transform-test.ts | 9 +- src/stories/api.stories.ts | 21 ++- src/stories/items.stories.ts | 37 +----- 19 files changed, 498 insertions(+), 349 deletions(-) create mode 100644 public/assets/qti-item/example-choice-item.xml create mode 100644 public/assets/qti-item/img/sign.png create mode 100644 src/lib/qti-item/components/index.ts create mode 100644 src/lib/qti-item/components/item-container.stories.ts create mode 100644 src/lib/qti-item/components/item-container.ts delete mode 100644 src/lib/qti-item/qti-item.mixin.ts create mode 100644 src/lib/qti-item/qti-item.stories.ts delete mode 100644 src/lib/qti-test/qti-test-conformance.spec.ts delete mode 100644 src/lib/qti-test/qti-test-conformance.stories.ts create mode 100644 src/lib/qti-test/qti-test.stories.ts diff --git a/public/assets/qti-item/example-choice-item.xml b/public/assets/qti-item/example-choice-item.xml new file mode 100644 index 00000000..ef4c28eb --- /dev/null +++ b/public/assets/qti-item/example-choice-item.xml @@ -0,0 +1,37 @@ + + + + + + ChoiceA + + + + + 0 + + + + + + 1 + + + +

Look at the text in the picture.

+

+ NEVER LEAVE LUGGAGE UNATTENDED +

+ + What does it say? + You must stay with your luggage at all times. + Do not let someone else look after your luggage. + Remember your luggage when you leave. + +
+ +
\ No newline at end of file diff --git a/public/assets/qti-item/img/sign.png b/public/assets/qti-item/img/sign.png new file mode 100644 index 0000000000000000000000000000000000000000..72200caa61f5e1deb327958753a6ddc4b6d0418f GIT binary patch literal 4029 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 + +const meta: Meta = { + 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`${template(args)}`; + }, + 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` + + + + `; + }, + 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` + + + + `; + }, + 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` + + + + + + `; + }, + play: ItemURL.play, + tags: ['!autodocs'] +}; + +export const ItemWithTemplateScale: Story = { + render: args => { + return html` + + + + + + `; + }, + play: ItemURL.play, + tags: ['!autodocs'] +}; diff --git a/src/lib/qti-item/components/item-container.ts b/src/lib/qti-item/components/item-container.ts new file mode 100644 index 00000000..5d628ec9 --- /dev/null +++ b/src/lib/qti-item/components/item-container.ts @@ -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'; + +/** + * `` 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 + * + * + * + * ``` + */ +@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 { + 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} + + ${until(this.itemDoc, html`Loading...`)} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'item-container': ItemContainer; + } +} diff --git a/src/lib/qti-item/index.ts b/src/lib/qti-item/index.ts index 33e44061..de5bbf7b 100644 --- a/src/lib/qti-item/index.ts +++ b/src/lib/qti-item/index.ts @@ -1,3 +1,2 @@ export * from './qti-item'; -export * from './qti-item.mixin'; - +export * from './components'; diff --git a/src/lib/qti-item/qti-item.mixin.ts b/src/lib/qti-item/qti-item.mixin.ts deleted file mode 100644 index e3aad8fa..00000000 --- a/src/lib/qti-item/qti-item.mixin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { QtiAssessmentItem } from '../qti-components'; - -type Constructor = new (...args: any[]) => T; - -export interface QtiItemInterface { - identifier?: string; - href?: string; - xmlDoc: DocumentFragment; - assessmentItem: QtiAssessmentItem | null; -} - -export function QtiItemMixin>(Base: T) { - class QtiItemClass extends Base { - @property({ type: String, reflect: true }) identifier?: string; - @property({ type: String }) href?: string; - - @property({ type: Object, attribute: false }) - xmlDoc!: DocumentFragment; // the XMLDocument - - protected createRenderRoot(): HTMLElement | DocumentFragment { - return this; - } - - get assessmentItem(): QtiAssessmentItem | null { - return this.renderRoot?.querySelector('qti-assessment-item'); - } - - async connectedCallback(): Promise { - super.connectedCallback(); - await this.updateComplete; - this.dispatchEvent( - new CustomEvent('qti-item-connected', { - bubbles: true, - composed: true, - detail: { identifier: this.identifier, href: this.href } - }) - ); - } - - render() { - return html`${this.xmlDoc}`; - } - } - return QtiItemClass as Constructor & T; -} diff --git a/src/lib/qti-item/qti-item.stories.ts b/src/lib/qti-item/qti-item.stories.ts new file mode 100644 index 00000000..2f068284 --- /dev/null +++ b/src/lib/qti-item/qti-item.stories.ts @@ -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; + +const meta: Meta = { + 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``)}`; + } +}; diff --git a/src/lib/qti-item/qti-item.ts b/src/lib/qti-item/qti-item.ts index 4f76e29c..ce7738b6 100644 --- a/src/lib/qti-item/qti-item.ts +++ b/src/lib/qti-item/qti-item.ts @@ -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'; +/** + * `` 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 `` inside '' will load or parse the item and render it. + * See `` for more details. + * + * ```html + * + * + * + * ``` + */ @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``; } } diff --git a/src/lib/qti-test/components/test-container.stories.ts b/src/lib/qti-test/components/test-container.stories.ts index 1187a8be..5799e2a3 100644 --- a/src/lib/qti-test/components/test-container.stories.ts +++ b/src/lib/qti-test/components/test-container.stories.ts @@ -2,7 +2,7 @@ import { expect } from '@storybook/test'; import type { Meta, StoryObj } from '@storybook/web-components'; import { getWcStorybookHelpers } from 'wc-storybook-helpers'; import { html } from 'lit'; -import { findByShadowTitle } from 'shadow-dom-testing-library'; +import { within } from 'shadow-dom-testing-library'; import { TestContainer } from './test-container'; import { qtiTransformTest } from '../../qti-transformers'; @@ -10,9 +10,9 @@ const { events, args, argTypes, template } = getWcStorybookHelpers('test-contain type Story = StoryObj; -const meta: Meta = { +const meta: Meta = { component: 'test-container', - args, + args: { ...args, 'test-url': '/assets/qti-conformance/Basic/T4-T7/assessment.xml' }, argTypes, parameters: { actions: { @@ -23,17 +23,14 @@ const meta: Meta = { export default meta; export const TestURL: Story = { - render: args => - html` - ${template(args)} - - `, - args: {}, + render: args => { + return html`${template(args)}`; + }, play: async ({ canvasElement }) => { - const itemElement = await findByShadowTitle(canvasElement, 'T1 - Test Entry - Item 1'); - expect(itemElement).toBeInTheDocument(); + const canvas = within(canvasElement); + + const testElement = await canvas.findByShadowTitle('T1 - Test Entry - Item 1'); + expect(testElement).toBeInTheDocument(); } }; diff --git a/src/lib/qti-test/components/test-container.ts b/src/lib/qti-test/components/test-container.ts index 3e7916e6..2a24f133 100644 --- a/src/lib/qti-test/components/test-container.ts +++ b/src/lib/qti-test/components/test-container.ts @@ -1,95 +1,90 @@ 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 { qtiTransformTest } from '../../qti-transformers'; /** - * `` is a custom element designed for hosting the qti-assessment-test. + * `` is a custom element designed for hosting the qti-assessment-item. * The `qti-assessment-test` 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. * - * ### Example Usage - * The `test-container` element to hosts the visual representation of the items. - * You can style the container by adding a class to the element. + * ### Styling + * Add a class to the element for styling. * * ```html - * - * + * + * * * ``` - * - * @tag test-container */ @customElement('test-container') export class TestContainer extends LitElement { - /** - * Internal state for the dynamically loaded content. - * This is a Promise resolving to the content that will be rendered. - */ + /** URL of the item to load */ + @property({ type: String, attribute: 'test-url' }) + testURL: string = null; + + /** A parsed HTML document */ @state() - private content: Promise; + testDoc: DocumentFragment = null; - @property({ type: String, attribute: 'test-url' }) - testURL = ''; + /** The raw XML string */ + @state() + testXML: string = null; + + /** Template content if provided */ + private templateContent = null; + + @watch('testURL', { waitUntilFirstUpdate: true }) + protected async handleTestURLChange() { + if (!this.testURL) return; + try { + const api = await qtiTransformTest().load(this.testURL); + this.testDoc = api.htmlDoc(); + } catch (error) { + console.error('Error loading or parsing XML:', error); + } + } - /** - * Preloaded content from a `