PageSourceSearch

https://edukors.org/structures/learn/learnView.js?v=56

js edukors.org collected 2026-10-08 20:27:39 UTC 17,868 bytes, 445 lines download raw bytes

1// Learn view: a horizontal header bar with a sprint timeline of activity
2// dots that scrolls sideways, and a wide detail area below. Selecting an
3// activity delegates the detail rendering to its type-specific class.
4
5import { TYPE_ICONS } from './types/learnActivity.js?v=56';
6import { createLearnActivity } from './learnActivityFactory.js?v=56';
7import { LearnStorage } from './learnStorage.js?v=56';
8import { LearnCertificate } from './learnCertificate.js?v=56';
9import { resolveSprint } from './learnAdaptive.js?v=56';
10
11// The helper pill always shows this illustrated face instead of the account
12// picture, so the call to action reads the same for every learner.
13const HELPER_PICTURE = './images/mgar/bye_1.png';
14
15export class LearnView {
16
17    #root = null;
18    #timeline = null;
19    #detail = null;
20    #activity = null;
21
22    #courseUuid = null;
23    #courseTitle = '';
24    #user = null;
25    #helper = null;
26    #groups = [];
27    #sprints = [];
28    #selectedUuid = null;
29    #onSelectActivity = null;
30    #onBack = null;
31    #onReload = null;
32
33    render = ({ courseUuid, courseTitle, sprints, selectedUuid, user, helper }) => {
34        this.#courseUuid = courseUuid ?? null;
35        this.#courseTitle = courseTitle || '';
36        this.#user = user ?? null;
37        this.#helper = helper ?? null;
38        this.#groups = Array.isArray(sprints) ? sprints : [];
39        this.#sprints = this.#resolveSprints();
40        this.#selectedUuid = selectedUuid || null;
41
42        this.#root = document.createElement('article');
43        this.#root.className = 'learn';
44
45        this.#timeline = this.#renderTimeline();
46        this.#detail = this.#renderDetail();
47
48        this.#root.appendChild(this.#renderCourseHeader());
49        this.#root.appendChild(this.#timeline);
50        this.#root.appendChild(this.#detail);
51        if (this.#helper) this.#root.appendChild(this.#renderHelper());
52
53        this.#selectInitialActivity();
54        this.#saveProgress();
55    };
56
57    // Persist a completion summary for this course so its card in the listing
58    // can be flagged as started (some steps done) or completed (all done).
59    #saveProgress = () => {
60        if (!this.#courseUuid) return;
61        const ordered = this.#orderedActivities();
62        const total = ordered.length;
63        const completed = ordered.filter((item) => LearnStorage.isCompleted(item.uuid)).length;
64        // `updatedAt` tells the home which course was touched last when several
65        // are under way; `title` lets it draw the "resume" card from local
66        // storage alone, before the course listing has come back from the API.
67        // Summaries saved before these existed simply lack them.
68        LearnStorage.setCourseProgress(this.#courseUuid, {
69            total,
70            completed,
71            updatedAt: Date.now(),
72            title:     this.#courseTitle,
73        });
74        AccessControl.recordLearn(this.#courseUuid);
75    };
76
77    #renderCourseHeader = () => {
78        const header = document.createElement('div');
79        header.className = 'learn-course-header';
80
81        // Only offered when there is somewhere to go back to. A course opened
82        // through a deep link (an LTI launch, for instance) has no listing
83        // behind it, and a button that does nothing is worse than no button.
84        if (this.#onBack) {
85            const back = document.createElement('button');
86            back.type = 'button';
87            back.className = 'learn-back';
88            back.innerHTML = Icons.render('arrowLeft', { size: 18 });
89            back.appendChild(document.createTextNode(Language.t('learn.back')));
90            back.addEventListener('click', () => this.#onBack());
91            header.appendChild(back);
92        }
93
94        const title = document.createElement('h2');
95        title.className = 'learn-course-heading';
96        title.textContent = this.#courseTitle;
97        header.appendChild(title);
98
99        const reload = document.createElement('button');
100        reload.type = 'button';
101        reload.className = 'learn-reload';
102        reload.title = Language.t('learn.reload');
103        reload.setAttribute('aria-label', Language.t('learn.reload'));
104        reload.innerHTML = Icons.render('refresh', { size: 18 });
105        reload.addEventListener('click', () => {
106            if (this.#onReload) this.#onReload();
107        });
108        header.appendChild(reload);
109
110        return header;
111    };
112
113    // A floating pill anchored to the course track: the face and the name of a
114    // real person the learner can write to, plus the promise of a reply within a
115    // day. Clicking it opens that conversation in the messages structure, which
116    // also takes care of asking a visitor to sign in first.
117    #renderHelper = () => {
118        const { uuid, name } = this.#helper;
119
120        const button = document.createElement('button');
121        button.type = 'button';
122        button.className = 'learn-helper';
123        button.title = Language.t('learn.helperAction', { name });
124        button.setAttribute('aria-label', Language.t('learn.helperAction', { name }));
125
126        button.appendChild(Shared.avatar(HELPER_PICTURE, name, 'learn-helper'));
127
128        const body = document.createElement('span');
129        body.className = 'learn-helper-body';
130
131        const title = document.createElement('span');
132        title.className = 'learn-helper-title';
133        title.textContent = Language.t('learn.helperTitle', { name });
134        body.appendChild(title);
135
136        const reply = document.createElement('span');
137        reply.className = 'learn-helper-reply';
138        reply.textContent = Language.t('learn.helperReply');
139        body.appendChild(reply);
140
141        button.appendChild(body);
142        button.addEventListener('click', () => {
143            document.dispatchEvent(new CustomEvent('messages:openThread', { detail: { uuid } }));
144        });
145
146        return button;
147    };
148
149    // On load, open the first activity the user has not completed yet, falling
150    // back to a remembered selection or the very first activity.
151    #selectInitialActivity = () => {
152        const ordered = this.#orderedActivities();
153        if (ordered.length === 0) return;
154
155        const remembered = ordered.find((item) => item.uuid === this.#selectedUuid);
156        const firstIncomplete = ordered.find((item) =>
156 !LearnStorage.isCompleted(item.uuid));
157        const activity = remembered ?? firstIncomplete ?? ordered[0];
158        this.#handleSelect(activity);
159    };
160
161    mount = (container) => {
162        container.appendChild(this.#root);
163        requestAnimationFrame(this.#centerActiveActivity);
164    };
165
166    // Horizontally scroll the timeline so the active step sits in the middle.
167    // Deferred to the next frame so the elements already have layout.
168    #centerActiveActivity = () => {
169        const active = this.#timeline.querySelector('.learn-activity.is-active');
170        if (!active) return;
171
172        const activeRect = active.getBoundingClientRect();
173        const timelineRect = this.#timeline.getBoundingClientRect();
174        const delta = (activeRect.left + activeRect.width / 2) - (timelineRect.left + timelineRect.width / 2);
175        this.#timeline.scrollLeft += delta;
176    };
177
178    onSelectActivity = (callback) => {
179        this.#onSelectActivity = callback;
180    };
181
182    onBack = (callback) => {
183        this.#onBack = callback;
184    };
185
186    onReload = (callback) => {
187        this.#onReload = callback;
188    };
189
190    #renderTimeline = () => {
191        const timeline = document.createElement('header');
192        timeline.className = 'learn-timeline';
193        this.#fillTimeline(timeline);
194        return timeline;
195    };
196
197    #fillTimeline = (timeline) => {
198        timeline.innerHTML = '';
199
200        if (this.#sprints.length === 0) {
201            const empty = document.createElement('p');
202            empty.className = 'learn-empty';
203            empty.textContent = Language.t('learn.empty');
204            timeline.appendChild(empty);
205            return;
206        }
207
208        const track = document.createElement('div');
209        track.className = 'learn-track';
210        this.#sprints.forEach((group) =>
210 track.appendChild(this.#renderSprint(group)));
211        track.appendChild(this.#renderCertificateBlock());
212        timeline.appendChild(track);
213    };
214
215    // Re-resolves the adaptive tracks and rebuilds the timeline in place, so a
216    // sprint whose assessment has just been scored replaces its spirit nodes with
217    // the selected track without leaving the course. The detail area is left
218    // untouched — the student stays on the activity they are reading.
219    #refreshTimeline = () => {
220        this.#sprints = this.#resolveSprints();
221        this.#fillTimeline(this.#timeline);
222        this.#saveProgress();
223    };
224
225    // An adaptive sprint (more than one track) is narrowed down to a single
226    // track; a linear one is returned untouched. See learnAdaptive.js.
227    #resolveSprints = () => this.#groups.map(resolveSprint);
228
229    // The final "Certificate" step closes the trail. It mimics a sprint block
230    // (spacer header keeps its dot aligned with the sprint dots) but is not a
231    // completable activity: clicking it opens the certificate popup once every
232    // activity has been completed.
233    #renderCertificateBlock = () => {
234        const block = document.createElement('div');
235        block.className = 'learn-sprint learn-sprint--certificate';
236
237        const header = document.createElement('div');
238        header.className = 'learn-sprint-header';
239        header.textContent = ' ';
240        block.appendChild(header);
241
242        const list = document.createElement('div');
243        list.className = 'learn-sprint-activities';
244
245        const row = document.createElement('button');
246        row.type = 'button';
247        row.className = 'learn-activity learn-activity--certificate';
248        if (this.#isCourseCompleted()) row.classList.add('is-complete');
249
250        const dot = document.createElement('span');
251        dot.className = 'learn-dot learn-dot--certificate';
252        dot.innerHTML = Icons.render('certificate', { size: 18 });
253
254        const label = document.createElement('span');
255        label.className = 'learn-activity-title';
256        label.textContent = Language.t('learn.certificate.step');
257
258        row.appendChild(dot);
259        row.appendChild(label);
260        row.addEventListener('click', this.#handleCertificateClick);
261
262        list.appendChild(row);
263        block.appendChild(list);
264        return block;
265    };
266
267    // A sprint still showing spirits hides activities that have not been picked
268    // yet, so the course cannot be complete while one is pending.
269    #isCourseCompleted = () => {
270        if (this.#pendingSprintIndex() >= 0) return false;
271        const ordered = this.#orderedActivities();
272        return ordered.length > 0 && ordered.every((item) => LearnStorage.isCompleted(item.uuid));
273    };
274
275    #handleCertificateClick = async () => {
276        if (!this.#isCourseCompleted()) {
277            await Dialog.alert(Language.t('learn.certificate.locked'));
278            return;
279        }
280        new LearnCertificate().show({ courseTitle: this.#courseTitle, user: this.#user });
281    };
282
283    #renderSprint = ({ sprint, activities, spirits = 0 }) => {
284        const block = document.createElement('div');
285        block.className = 'learn-sprint';
286
287        const header = document.createElement('div');
288        header.className = 'learn-sprint-header';
289        header.textContent = this.#sprints.length > 1 ? `${Language.t('learn.sprint')} ${sprint}` : ' ';
290        block.appendChild(header);
291
292        const list = document.createElement('div');
293        list.className = 'learn-sprint-activities';
294        activities.forEach((activity) => list.appendChild(this.#renderActivity(activity)));
295        Array.from({ length: spirits }).forEach(() =>
295 list.appendChild(this.#renderSpirit()));
296        block.appendChild(list);
297
298        return block;
299    };
300
301    // Placeholder node of an adaptive sprint whose track is still undecided: a
302    // dotted, unclickable dot standing for "an activity will appear here once the
303    // assessment is scored".
304    #renderSpirit = () => {
305        const row = document.createElement('div');
306        row.className = 'learn-activity learn-activity--spirit';
307        row.title = Language.t('learn.spirit');
308
309        const dot = document.createElement('span');
310        dot.className = 'learn-dot learn-dot--spirit';
311
312        const label = document.createElement('span');
313        label.className = 'learn-activity-title';
314        label.textContent = Language.t('learn.spirit');
315
316        row.appendChild(dot);
317        row.appendChild(label);
318        return row;
319    };
320
321    #renderActivity = (activity) => {
322        const { uuid, type, title } = activity;
323        const iconName = TYPE_ICONS[type] || 'learn';
324
325        const row = document.createElement('button');
326        row.type = 'button';
327        row.className = 'learn-activity';
328        row.dataset.uuid = uuid;
329        if (uuid === this.#selectedUuid) row.classList.add('is-active');
330
331        if (LearnStorage.isCompleted(uuid)) row.classList.add('is-complete');
332
333        const dot = document.createElement('span');
334        dot.className = `learn-dot learn-dot--${type}`;
335        dot.innerHTML = Icons.render(iconName, { size: 18 });
336        dot.title = Language.t(`learn.types.${type}`);
337
338        const label = document.createElement('span');
339        label.className = 'learn-activity-title';
340        label.textContent = title || '';
341
342        row.appendChild(dot);
343        row.appendChild(label);
344        row.addEventListener('click', () => this.#handleSelect(activity));
345        return row;
346    };
347
348    #renderDetail = () => {
349        const detail = document.createElement('section');
350        detail.className = 'learn-detail';
351        detail.appendChild(this.#renderHint());
352        return detail;
353    };
354
355    #renderHint = () => {
356        const placeholder = document.createElement('div');
357        placeholder.className = 'learn-placeholder';
358
359        const hint = document.createElement('p');
360        hint.className = 'learn-placeholder-hint';
361        hint.textContent = Language.t('learn.selectHint');
362
363        placeholder.appendChild(hint);
364        return placeholder;
365    };
366
367    #handleSelect = (activity) => {
368        this.#selectedUuid = activity.uuid;
369        this.#timeline.querySelectorAll('.learn-activity').forEach((element) => {
370            element.classList.toggle('is-active', element.dataset.uuid === activity.uuid);
371        });
372
373        const context = {
374            isCompleted: LearnStorage.isCompleted(activity.uuid),
375            canComplete: this.#canComplete(activity),
376            canUncheck:  this.#canUncheck(activity),
377            isAdmin:     Boolean(this.#user?.isAdmin),
378            onToggle:    (isCompleted) => this.#handleToggleComplete(activity, isCompleted),
379            onRefresh:   this.#refreshTimeline,
380        };
381
382        this.#destroyActivity();
383        this.#activity = createLearnActivity(activity, context);
384        this.#detail.innerHTML = '';
385        this.#detail.appendChild(this.#activity.render());
386        if (this.#onSelectActivity) this.#onSelectActivity(activity);
387    };
388
389    // Activity types that keep timers, animation loops or window listeners
390    // (the quiz interfaces, for instance) are torn down before being replaced.
391    #destroyActivity = () => {
392        if (this.#activity?.destroy) this.#activity.destroy();
393        this.#activity = null;
394    };
395
396    destroy = () => this.#destroyActivity();
397
398    // An activity can be completed only once the previous one is completed and
399    // no earlier sprint is still showing spirits.
400    #canComplete = (activity) => {
401        if (this.#isBeyondSpirits(activity)) return false;
402        const ordered = this.#orderedActivities();
403        const index = ordered.findIndex((item) => item.uuid === activity.uuid);
404        if (index <= 0) return true;
405        return LearnStorage.isCompleted(ordered[index - 1].uuid);
406    };
407
408    // The first sprint still waiting for its assessment score, or -1 when every
409    // sprint is resolved. Its spirit nodes stand for activities that only exist
410    // once the score picks a track.
411    #pendingSprintIndex = () => this.#sprints.findIndex((group) => (group.spirits ?? 0) > 0);
412
413    // Nothing past an unresolved sprint may be reached: the trail continues
414    // through that sprint's own track, not around it.
415    #isBeyondSpirits = (activity) => {
416        const pending = this.#pendingSprintIndex();
417        if (pending < 0) return false;
418        const index = this.#sprints.findIndex((group) => group.activities
419            .some((item) => item.uuid === activity.uuid));
420        return index > pending;
421    };
422
423    // An activity can be unchecked only while the next one is still unchecked.
424    #canUncheck = (activity) => {
425        const ordered = this.#orderedActivities();
426        const index = ordered.findIndex((item) => item.uuid === activity.uuid);
427        if (index === ordered.length - 1) return true;
428        return !LearnStorage.isCompleted(ordered[index + 1].uuid);
429    };
430
431    #orderedActivities = () => this.#sprints.flatMap((group) => group.activities);
432
433    #handleToggleComplete = (activity, isCompleted) => {
434        LearnStorage.setCompleted(activity.uuid, isCompleted);
435        this.#saveProgress();
436        const row = this.#timeline.querySelector(`.learn-activity[data-uuid="${activity.uuid}"]`);
437        if (row) row.classList.toggle('is-complete', isCompleted);
438        const certificateRow = this.#timeline.querySelector('.learn-activity--certificate');
439        if (certificateRow) certificateRow.classList.toggle('is-complete', this.#isCourseCompleted());
440        // Re-render the detail so the new completion state applies at once
441        // (e.g. a completed form becomes read-only without reselecting it).
442        this.#handleSelect(activity);
443    };
444
445}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.