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.