From 135847ff9de68d566a9906ffc1ce52648d116fa7 Mon Sep 17 00:00:00 2001 From: Patrick de Klein Date: Wed, 29 Jan 2025 13:37:50 +0100 Subject: [PATCH] feat: navigate by sections --- public/assets/qti-test-package/assessment.xml | 2 +- src/lib/qti-test/components/index.ts | 4 +- .../test-paging-buttons-stamp.stories.ts | 17 -- .../components/test-paging-buttons-stamp.ts | 27 ---- .../test-section-buttons-stamp.stories.ts | 74 +++++++++ .../components/test-section-buttons-stamp.ts | 47 ++++++ .../qti-test/components/test-section-link.ts | 47 ++++++ .../core/mixins/test-navigation.mixin.ts | 150 +++++++++--------- 8 files changed, 249 insertions(+), 119 deletions(-) create mode 100644 src/lib/qti-test/components/test-section-buttons-stamp.stories.ts create mode 100644 src/lib/qti-test/components/test-section-buttons-stamp.ts create mode 100644 src/lib/qti-test/components/test-section-link.ts diff --git a/public/assets/qti-test-package/assessment.xml b/public/assets/qti-test-package/assessment.xml index 4ee16cf4..66bdf22c 100644 --- a/public/assets/qti-test-package/assessment.xml +++ b/public/assets/qti-test-package/assessment.xml @@ -29,7 +29,7 @@ - + diff --git a/src/lib/qti-test/components/index.ts b/src/lib/qti-test/components/index.ts index 87af7609..eeeaa404 100644 --- a/src/lib/qti-test/components/index.ts +++ b/src/lib/qti-test/components/index.ts @@ -1,4 +1,5 @@ /* test functionality webcomponents which are tested and can be used in your project */ +export * from './test-navigation'; export * from './test-next'; export * from './test-prev'; export * from './test-view'; @@ -6,6 +7,7 @@ export * from './test-item-link'; export * from './test-end-attempt'; export * from './test-show-correct-response'; export * from './test-paging-buttons-stamp'; -export * from './test-navigation'; export * from './test-container'; export * from './test-print-item-variables'; +export * from './test-section-buttons-stamp'; +export * from './test-section-link'; diff --git a/src/lib/qti-test/components/test-paging-buttons-stamp.stories.ts b/src/lib/qti-test/components/test-paging-buttons-stamp.stories.ts index 51c113f0..269f6589 100644 --- a/src/lib/qti-test/components/test-paging-buttons-stamp.stories.ts +++ b/src/lib/qti-test/components/test-paging-buttons-stamp.stories.ts @@ -79,23 +79,6 @@ export const Title: Story = { {{ item.index }}:{{ item.title }} -
-
title:
-
{{ item.title }}
-
type:
-
{{ item.type }}
-
active:
-
{{ item.active }}
-
correct:
-
{{ item.correct }}
-
incorrect:
-
{{ item.incorrect }}
-
completed:
-
{{ item.completed }}
-
response:
-
{{ item.response }}
-
{{ item.volgnummer }}
-
diff --git a/src/lib/qti-test/components/test-paging-buttons-stamp.ts b/src/lib/qti-test/components/test-paging-buttons-stamp.ts index 8537d66d..1796b3f3 100644 --- a/src/lib/qti-test/components/test-paging-buttons-stamp.ts +++ b/src/lib/qti-test/components/test-paging-buttons-stamp.ts @@ -37,33 +37,6 @@ export class TestPagingButtonsStamp extends LitElement { const items = this.computedContext.testParts.flatMap(testPart => testPart.sections.flatMap(section => section.items) ); - // const items = this._testContext.items.reduce( - // (acc, item) => { - // const isDepInfoItem = item.category?.split(' ').includes(this.skipOnCategory); - // const newIndex = isDepInfoItem ? 'i' : acc.counter++; - // acc.result.push({ - // ...item, - // newIndex // Assign the new index, which only increments for non-info items - // }); - // return acc; - // }, - // { counter: 0, result: [] } - // ).result; - - // // Get the index of the current item - // const itemIndex = items.findIndex(item => item.identifier === this._sessionContext.navItemId); - - // // Calculate the start and end range based on maxDisplayedItems - // const start = Math.max(0, itemIndex - this.maxDisplayedItems); - // const end = Math.min(items.length, itemIndex + this.maxDisplayedItems + 1); - - // // console.log('start', start, 'end', end); - // // Adjust the items array to only include the clamped range - // const clampedItems = items.slice(start, end); - - // const items = this._testContext.items; - // const items = this.testContextController.computedContext.items; - // console.log(items); return html` ${items.map(item => this.myTemplate({ item }))} `; } diff --git a/src/lib/qti-test/components/test-section-buttons-stamp.stories.ts b/src/lib/qti-test/components/test-section-buttons-stamp.stories.ts new file mode 100644 index 00000000..faa73991 --- /dev/null +++ b/src/lib/qti-test/components/test-section-buttons-stamp.stories.ts @@ -0,0 +1,74 @@ +import { getWcStorybookHelpers } from 'wc-storybook-helpers'; +import { html } from 'lit'; + +import type { Meta, StoryObj } from '@storybook/web-components'; +import type { TestSectionButtonsStamp } from './test-section-buttons-stamp'; + +import '../../../../.storybook/utilities.css'; + +const { events, args, argTypes, template } = getWcStorybookHelpers('test-section-buttons-stamp'); + +type Story = StoryObj; + +const meta: Meta = { + component: 'test-section-buttons-stamp', + args, + argTypes, + parameters: { + actions: { + handles: events + } + } +}; +export default meta; + +export const Default: Story = { + render: args => + html` + + + ${template( + args, + html`` + )} + + ` +}; + +export const Title: Story = { + render: () => html` + + + + + + + + + ` +}; diff --git a/src/lib/qti-test/components/test-section-buttons-stamp.ts b/src/lib/qti-test/components/test-section-buttons-stamp.ts new file mode 100644 index 00000000..1aee9546 --- /dev/null +++ b/src/lib/qti-test/components/test-section-buttons-stamp.ts @@ -0,0 +1,47 @@ +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { prepareTemplate } from 'stampino'; +import { consume } from '@lit/context'; + +import { computedContext } from '../../exports/computed.context'; + +import type { ComputedContext } from '../../exports/computed.context'; +import type { TemplateFunction } from 'stampino'; + +@customElement('test-section-buttons-stamp') +export class TestSectionButtonsStamp extends LitElement { + @consume({ context: computedContext, subscribe: true }) + private computedContext: ComputedContext; + + myTemplate: TemplateFunction; + private _internals: ElementInternals; + + protected createRenderRoot(): HTMLElement | DocumentFragment { + return this; + } + + constructor() { + super(); + this._internals = this.attachInternals(); + this._internals.ariaLabel = 'pagination'; + } + + connectedCallback(): void { + super.connectedCallback(); + const templateElement = this.querySelector('template'); + this.myTemplate = prepareTemplate(templateElement); + } + + render() { + if (!this.computedContext) return html``; + const sections = this.computedContext.testParts.flatMap(testPart => testPart.sections); + + return html` ${sections.map(item => this.myTemplate({ item }))} `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'test-section-buttons-stamp': TestSectionButtonsStamp; + } +} diff --git a/src/lib/qti-test/components/test-section-link.ts b/src/lib/qti-test/components/test-section-link.ts new file mode 100644 index 00000000..f1bc24e3 --- /dev/null +++ b/src/lib/qti-test/components/test-section-link.ts @@ -0,0 +1,47 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import * as styles from './styles'; + +@customElement('test-section-link') +export class TestSectionLink extends LitElement { + static styles = css` + :host { + ${styles.btn}; + } + :host([disabled]) { + ${styles.dis}; + } + `; + + @property({ type: String, attribute: 'section-id' }) + private sectionId: string = null; + + private _requestItem(identifier: string) { + this.dispatchEvent( + new CustomEvent('qti-request-navigation', { + composed: true, + bubbles: true, + detail: { + type: 'section', + id: identifier + } + }) + ); + } + + constructor() { + super(); + this.addEventListener('click', () => this._requestItem(this.sectionId)); + } + + render() { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'test-section-link': TestSectionLink; + } +} diff --git a/src/lib/qti-test/core/mixins/test-navigation.mixin.ts b/src/lib/qti-test/core/mixins/test-navigation.mixin.ts index f466cdaf..36df6766 100644 --- a/src/lib/qti-test/core/mixins/test-navigation.mixin.ts +++ b/src/lib/qti-test/core/mixins/test-navigation.mixin.ts @@ -15,38 +15,55 @@ export const TestNavigationMixin = >(superClass: constructor(...args: any[]) { super(...args); - // Load either a single item or all items in a section this.addEventListener( 'qti-request-navigation', - ({ detail }: CustomEvent<{ type: 'item' | 'section'; id: string }>) => { + async ({ detail }: CustomEvent<{ type: 'item' | 'section'; id: string }>) => { if (!detail?.id) return; - this._clearLoadedItems(); if (detail.type === 'item') { - this._loadSingleItem(detail.id); + await this._loadItems([detail.id]); + + const navItemId = detail.id; + const itemRefEl = this._testElement?.querySelector( + `qti-assessment-item-ref[identifier="${detail.id}"]` + ); + const navPartId = itemRefEl.closest('qti-test-part')?.identifier; + const navSectionId = itemRefEl.closest('qti-assessment-section')?.identifier; + + this.sessionContext = { ...this.sessionContext, navPartId, navSectionId, navItemId, navItemLoading: false }; } else if (detail.type === 'section') { - this._loadSectionItems(detail.id); + const itemIds = this._getSectionItemIds(detail.id); + await this._loadItems(itemIds); + + const navSectionId = detail.id; + const sectionRefEl = this._testElement?.querySelector( + `qti-assessment-section[identifier="${navSectionId}"]` + ); + const navPartId = sectionRefEl.closest('qti-test-part')?.identifier; + + this.sessionContext = { + ...this.sessionContext, + navPartId, + navSectionId, + navItemId: null, + navItemLoading: false + }; } } ); this.addEventListener('qti-assessment-test-connected', (e: CustomEvent) => { this._testElement = e.detail; - - // Determine the navigation target let id: string | undefined; if (this.navigate === 'section') { - // Navigate to the first section if navigation mode is 'section' id = this._testElement.querySelector('qti-assessment-section')?.identifier; } else { - // Use the session context navigation ID if available, otherwise fallback to the first item id = this.sessionContext.navItemId ?? this._testElement.querySelector('qti-assessment-item-ref')?.identifier; } - // Dispatch navigation event if an ID is found if (id) { this.dispatchEvent( new CustomEvent('qti-request-navigation', { @@ -59,74 +76,66 @@ export const TestNavigationMixin = >(superClass: }); } - private _loadSingleItem(navItemId: string, cancelPreviousRequest = true): Promise { - return new Promise((resolve, reject) => { - const itemRefEl = this._testElement?.querySelector( - `qti-assessment-item-ref[identifier="${navItemId}"]` - ); + private async _loadItems(itemIds: string[]): Promise { + let results; + if (!this._testElement || itemIds.length === 0) return; - if (!itemRefEl) { - console.warn(`Item with identifier "${navItemId}" not found.`); - return reject(new Error(`Item not found: ${navItemId}`)); - } + const itemRefEls = itemIds.map(id => + this._testElement!.querySelector(`qti-assessment-item-ref[identifier="${id}"]`) + ); - const href = itemRefEl.href; - const navPartId = itemRefEl.closest('qti-test-part')?.identifier; - const navSectionId = itemRefEl.closest('qti-assessment-section')?.identifier; - - this.sessionContext = { ...this.sessionContext, navPartId, navSectionId, navItemId, navItemLoading: true }; - const promise = this._loadItemRequest(href, cancelPreviousRequest); - - promise - ?.then(doc => { - itemRefEl.xmlDoc = doc; - requestAnimationFrame(() => - this.dispatchEvent( - new CustomEvent('qti-test-connected', { - detail: [{ identifier: navItemId, element: itemRefEl }], - bubbles: true, - composed: true - }) - ) - ); - this.sessionContext = { ...this.sessionContext, navItemLoading: false }; - resolve(); - }) - .catch(error => { - console.error('Failed to load item:', error); - reject(error); - }); + if (itemRefEls.includes(null)) { + console.warn(`One or more items not found: ${itemIds}`); + return; + } + + this._clearLoadedItems(); + this.sessionContext = { ...this.sessionContext, navItemLoading: true }; + + const itemLoadPromises = itemRefEls.map(async itemRef => { + if (!itemRef) return null; + return { itemRef, doc: await this._loadItemRequest(itemRef.href) }; }); + + try { + results = await Promise.all(itemLoadPromises); + + results.forEach(({ itemRef, doc }) => { + if (itemRef && doc) itemRef.xmlDoc = doc; + }); + + requestAnimationFrame(() => { + this.dispatchEvent( + new CustomEvent('qti-test-connected', { + detail: results.map(({ itemRef }) => ({ identifier: itemRef?.identifier, element: itemRef })), + bubbles: true, + composed: true + }) + ); + + console.info(`Loaded ${results.length} items successfully.`); + }); + } catch (error) { + console.error('Error loading items:', error); + } + return results; } - private _loadSectionItems(navSectionId: string): void { - const sectionRefEl = this._testElement?.querySelector( + private _getSectionItemIds(navSectionId: string): string[] { + const sectionRefEl = this._testElement?.querySelector( `qti-assessment-section[identifier="${navSectionId}"]` ); if (!sectionRefEl) { console.warn(`Section with identifier "${navSectionId}" not found.`); - return; + return []; } - const itemRefEls = this._testElement?.querySelectorAll( - `qti-assessment-section[identifier="${navSectionId}"] > qti-assessment-item-ref` - ); - - const navPartId = sectionRefEl.closest('qti-test-part')?.identifier; - this.sessionContext = { ...this.sessionContext, navPartId, navSectionId, navItemId: null }; - - const items = Array.from(itemRefEls || []).map(itemRef => itemRef.identifier); - - const promises = items.map(itemId => this._loadSingleItem(itemId, false)); - - Promise.all(promises) - .then(results => { - console.info('All items in section loaded successfully.'); - }) - .catch(error => { - console.error('One or more items failed to load:', error); - }); + return Array.from( + this._testElement!.querySelectorAll( + `qti-assessment-section[identifier="${navSectionId}"] > qti-assessment-item-ref` + ) + ).map(itemRef => itemRef.identifier); } private _clearLoadedItems(): void { @@ -138,18 +147,13 @@ export const TestNavigationMixin = >(superClass: }); } - private _loadItemRequest(href: string, cancelPreviousRequest: boolean = true): Promise { + private _loadItemRequest(href: string): Promise { const event = new CustomEvent('qti-load-item-request', { bubbles: true, composed: true, - detail: { - href, - promise: null, - cancelPreviousRequest - } + detail: { href, promise: null } }); this.dispatchEvent(event); - return event.detail.promise; } }