@windward/core 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/components/Content/Blocks/Accordion.vue +9 -15
  3. package/components/Content/Blocks/BlockQuote.vue +29 -4
  4. package/components/Content/Blocks/ClickableIcons.vue +22 -9
  5. package/components/Content/Blocks/Email.vue +11 -4
  6. package/components/Content/Blocks/Feedback/FeedbackAnalytics.vue +179 -0
  7. package/components/Content/Blocks/Feedback.vue +115 -111
  8. package/components/Content/Blocks/FileDownload.vue +2 -2
  9. package/components/Content/Blocks/Image.vue +144 -0
  10. package/components/Content/Blocks/OpenResponse.vue +419 -5
  11. package/components/Content/Blocks/ScenarioChoice.vue +11 -2
  12. package/components/Content/Blocks/Tab.vue +16 -29
  13. package/components/Content/Blocks/UserUpload.vue +66 -38
  14. package/components/Content/Blocks/Video.vue +377 -28
  15. package/components/Settings/AccordionSettings.vue +3 -15
  16. package/components/Settings/BlockQuoteSettings.vue +6 -4
  17. package/components/Settings/ClickableIconsSettings.vue +24 -10
  18. package/components/Settings/EmailSettings.vue +3 -11
  19. package/components/Settings/FileDownloadSettings.vue +8 -2
  20. package/components/Settings/ImageSettings.vue +26 -0
  21. package/components/Settings/OpenResponseCollateSettings.vue +10 -0
  22. package/components/Settings/OpenResponseSettings.vue +67 -7
  23. package/components/Settings/ScenarioChoiceSettings.vue +11 -5
  24. package/components/Settings/TabSettings.vue +3 -18
  25. package/components/Settings/UserUploadSettings.vue +16 -8
  26. package/components/Settings/VideoSettings/SourcePicker.vue +55 -21
  27. package/components/Settings/VideoSettings.vue +18 -2
  28. package/components/utils/ContentViewer.vue +180 -1
  29. package/components/utils/glossary/GlossaryToolTip.vue +4 -23
  30. package/helpers/GlossaryHelper.ts +4 -7
  31. package/i18n/en-US/components/content/blocks/accordion.ts +3 -0
  32. package/i18n/en-US/components/content/blocks/block_quote.ts +3 -1
  33. package/i18n/en-US/components/content/blocks/feedback.ts +2 -0
  34. package/i18n/en-US/components/content/blocks/file_download.ts +2 -1
  35. package/i18n/en-US/components/content/blocks/index.ts +2 -0
  36. package/i18n/en-US/components/content/blocks/open_response.ts +19 -1
  37. package/i18n/en-US/components/content/blocks/open_response_collate.ts +1 -1
  38. package/i18n/en-US/components/content/blocks/scenario_choice.ts +2 -0
  39. package/i18n/en-US/components/content/blocks/user_upload.ts +2 -1
  40. package/i18n/en-US/components/settings/accordion.ts +2 -1
  41. package/i18n/en-US/components/settings/block_quote.ts +1 -1
  42. package/i18n/en-US/components/settings/clickable_icon.ts +5 -0
  43. package/i18n/en-US/components/settings/email.ts +2 -1
  44. package/i18n/en-US/components/settings/file_download.ts +2 -2
  45. package/i18n/en-US/components/settings/image.ts +1 -0
  46. package/i18n/en-US/components/settings/open_response.ts +8 -0
  47. package/i18n/en-US/components/settings/open_response_collate.ts +3 -0
  48. package/i18n/en-US/components/settings/scenario_choice.ts +3 -1
  49. package/i18n/en-US/components/settings/tab.ts +4 -3
  50. package/i18n/en-US/components/settings/user_upload.ts +1 -0
  51. package/i18n/en-US/components/settings/video.ts +3 -1
  52. package/i18n/en-US/shared/content_blocks.ts +1 -1
  53. package/i18n/es-ES/components/content/blocks/accordion.ts +3 -0
  54. package/i18n/es-ES/components/content/blocks/block_quote.ts +3 -1
  55. package/i18n/es-ES/components/content/blocks/feedback.ts +2 -0
  56. package/i18n/es-ES/components/content/blocks/file_download.ts +2 -1
  57. package/i18n/es-ES/components/content/blocks/index.ts +2 -0
  58. package/i18n/es-ES/components/content/blocks/open_response.ts +19 -2
  59. package/i18n/es-ES/components/content/blocks/open_response_collate.ts +1 -1
  60. package/i18n/es-ES/components/content/blocks/scenario_choice.ts +2 -0
  61. package/i18n/es-ES/components/content/blocks/user_upload.ts +2 -1
  62. package/i18n/es-ES/components/settings/accordion.ts +4 -2
  63. package/i18n/es-ES/components/settings/block_quote.ts +1 -1
  64. package/i18n/es-ES/components/settings/clickable_icon.ts +7 -0
  65. package/i18n/es-ES/components/settings/email.ts +2 -1
  66. package/i18n/es-ES/components/settings/image.ts +1 -0
  67. package/i18n/es-ES/components/settings/open_response.ts +8 -0
  68. package/i18n/es-ES/components/settings/open_response_collate.ts +3 -0
  69. package/i18n/es-ES/components/settings/scenario_choice.ts +3 -1
  70. package/i18n/es-ES/components/settings/tab.ts +3 -2
  71. package/i18n/es-ES/components/settings/user_upload.ts +1 -0
  72. package/i18n/es-ES/components/settings/video.ts +3 -1
  73. package/i18n/es-ES/shared/content_blocks.ts +1 -1
  74. package/i18n/sv-SE/components/content/blocks/accordion.ts +3 -0
  75. package/i18n/sv-SE/components/content/blocks/block_quote.ts +3 -1
  76. package/i18n/sv-SE/components/content/blocks/feedback.ts +2 -0
  77. package/i18n/sv-SE/components/content/blocks/file_download.ts +2 -1
  78. package/i18n/sv-SE/components/content/blocks/index.ts +2 -0
  79. package/i18n/sv-SE/components/content/blocks/open_response.ts +19 -2
  80. package/i18n/sv-SE/components/content/blocks/open_response_collate.ts +1 -1
  81. package/i18n/sv-SE/components/content/blocks/scenario_choice.ts +2 -0
  82. package/i18n/sv-SE/components/content/blocks/user_upload.ts +2 -1
  83. package/i18n/sv-SE/components/settings/accordion.ts +2 -1
  84. package/i18n/sv-SE/components/settings/block_quote.ts +1 -1
  85. package/i18n/sv-SE/components/settings/clickable_icon.ts +6 -0
  86. package/i18n/sv-SE/components/settings/email.ts +2 -1
  87. package/i18n/sv-SE/components/settings/image.ts +1 -0
  88. package/i18n/sv-SE/components/settings/open_response.ts +8 -0
  89. package/i18n/sv-SE/components/settings/open_response_collate.ts +3 -0
  90. package/i18n/sv-SE/components/settings/scenario_choice.ts +3 -1
  91. package/i18n/sv-SE/components/settings/tab.ts +5 -3
  92. package/i18n/sv-SE/components/settings/user_upload.ts +1 -0
  93. package/i18n/sv-SE/components/settings/video.ts +3 -1
  94. package/i18n/sv-SE/shared/content_blocks.ts +1 -1
  95. package/models/SurveyResultMetric.ts +8 -0
  96. package/package.json +2 -2
  97. package/plugin.js +8 -0
  98. package/test/Components/Content/Blocks/Feedback/FeedbackTemplates/FeedbackAnalytics.spec.js +23 -0
  99. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionLikert.spec.js +1 -1
  100. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionOpenResponse.spec.js +1 -1
  101. package/test/Components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionTrueFalse.spec.js +1 -1
  102. package/test/Components/Settings/AccordionSettings.spec.js +0 -13
  103. package/test/Components/Settings/ClickableIconsSettings.spec.js +1 -12
  104. package/test/Components/Settings/EmailSettings.spec.js +0 -9
  105. package/test/Components/Settings/TabSettings.spec.js +0 -13
  106. package/test/helpers/GlossaryHelper.spec.js +8 -8
  107. package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionLikert.vue +1 -1
  108. package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionOpenResponse.vue +1 -1
  109. /package/components/Content/Blocks/{FeedbackTemplates → Feedback/FeedbackTemplates}/FeedbackQuestionTrueFalse.vue +0 -0
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :class="blockDirectionClasses" :dir="blockTextDirection">
3
3
  <v-container
4
4
  v-if="
5
5
  block.metadata.config.title ||
@@ -64,7 +64,11 @@
64
64
  class="alert-text"
65
65
  ></TextViewer>
66
66
  </v-alert>
67
- <div v-if="block.metadata.config.sample_response">
67
+ <div
68
+ v-if="
69
+ block.metadata.config.sample_response && !aiModeEnabled
70
+ "
71
+ >
68
72
  <p>
69
73
  {{
70
74
  $t(
@@ -80,11 +84,145 @@
80
84
  ></TextViewer>
81
85
  </v-alert>
82
86
  </div>
87
+ <div v-if="aiModeEnabled" class="pt-6">
88
+ <p>
89
+ {{
90
+ $t(
91
+ 'windward.core.components.content.blocks.open_response.ai_feedback'
92
+ )
93
+ }}
94
+ </p>
95
+ <v-alert v-if="aiFeedbackError" type="error">
96
+ {{ aiFeedbackError }}
97
+ <div class="pt-2">
98
+ <v-btn
99
+ small
100
+ color="primary"
101
+ elevation="0"
102
+ :disabled="isGeneratingFeedback"
103
+ @click="onGenerateFeedback"
104
+ >
105
+ {{
106
+ $t(
107
+ 'windward.core.components.content.blocks.open_response.ai_feedback_retry'
108
+ )
109
+ }}
110
+ </v-btn>
111
+ </div>
112
+ </v-alert>
113
+ <v-alert
114
+ v-else-if="aiFeedbackResult && aiFeedbackResult.feedback_html"
115
+ color="light-green lighten-5"
116
+ >
117
+ <div
118
+ v-if="
119
+ aiFeedbackResult &&
120
+ aiFeedbackResult.understanding_level
121
+ "
122
+ class="alert-text mb-2"
123
+ >
124
+ <strong>
125
+ {{
126
+ $t(
127
+ 'windward.core.components.content.blocks.open_response.understanding_level'
128
+ )
129
+ }}:
130
+ </strong>
131
+ {{
132
+ getUnderstandingLevelLabel(
133
+ aiFeedbackResult.understanding_level
134
+ )
135
+ }}
136
+ </div>
137
+ <TextViewer
138
+ v-model="aiFeedbackResult.feedback_html"
139
+ :height="200"
140
+ class="alert-text"
141
+ ></TextViewer>
142
+ </v-alert>
143
+ <v-alert
144
+ v-else-if="isGeneratingFeedback"
145
+ color="grey lighten-4"
146
+ >
147
+ <div class="d-flex align-center">
148
+ <v-progress-circular
149
+ indeterminate
150
+ :size="18"
151
+ :width="2"
152
+ color="primary"
153
+ class="mr-2"
154
+ ></v-progress-circular>
155
+ <span>
156
+ {{
157
+ $t(
158
+ 'windward.core.components.content.blocks.open_response.ai_feedback_generating'
159
+ )
160
+ }}
161
+ </span>
162
+ <span v-if="aiFeedbackJob && aiFeedbackJob.progress">
163
+ &nbsp;({{ aiFeedbackJob.progress }}%)
164
+ </span>
165
+ </div>
166
+ </v-alert>
167
+ <v-alert v-else color="grey lighten-4">
168
+ {{
169
+ $t(
170
+ 'windward.core.components.content.blocks.open_response.ai_feedback_not_available'
171
+ )
172
+ }}
173
+ </v-alert>
174
+
175
+ <div
176
+ v-if="
177
+ aiFeedbackResult &&
178
+ aiFeedbackResult.citations &&
179
+ aiFeedbackResult.citations.length
180
+ "
181
+ class="pt-4"
182
+ >
183
+ <p>
184
+ {{
185
+ $t(
186
+ 'windward.core.components.content.blocks.open_response.ai_feedback_sources'
187
+ )
188
+ }}
189
+ </p>
190
+ <v-list dense class="pt-0 pb-0">
191
+ <v-list-item
192
+ v-for="citation in aiFeedbackResult.citations"
193
+ :key="citation.id"
194
+ class="pl-0 pr-0"
195
+ >
196
+ <v-list-item-content class="pl-0 pr-0">
197
+ <nuxt-link
198
+ v-if="getCitationLink(citation.id)"
199
+ :to="getCitationLink(citation.id)"
200
+ >
201
+ {{
202
+ citation.title ||
203
+ $t(
204
+ 'windward.core.components.content.blocks.open_response.untitled_page'
205
+ )
206
+ }}
207
+ </nuxt-link>
208
+ <span v-else>
209
+ {{
210
+ citation.title ||
211
+ $t(
212
+ 'windward.core.components.content.blocks.open_response.untitled_page'
213
+ )
214
+ }}
215
+ </span>
216
+ </v-list-item-content>
217
+ </v-list-item>
218
+ </v-list>
219
+ </div>
220
+ </div>
83
221
  <p class="pa-3 text-center">
84
222
  <v-btn
85
223
  elevation="0"
86
224
  color="primary"
87
- @click="submitted = false"
225
+ @click="onClickEdit"
88
226
  >{{ $t('shared.forms.edit') }}</v-btn
89
227
  >
90
228
  </p>
@@ -115,6 +253,8 @@ import _ from 'lodash'
115
253
  import { mapGetters } from 'vuex'
116
254
  import BaseContentBlock from '~/components/Content/Blocks/BaseContentBlock'
117
255
  import UserContentBlockState from '~/models/UserContentBlockState'
256
+ import Enrollment from '~/models/Enrollment'
257
+ import ContentBlock from '~/models/ContentBlock'
118
258
  import Crypto from '~/helpers/Crypto'
119
259
  import TextViewer from '~/components/Text/TextViewer'
120
260
  import TextEditor from '~/components/Text/TextEditor'
@@ -128,17 +268,32 @@ export default {
128
268
  extends: BaseContentBlock,
129
269
  data() {
130
270
  return {
271
+ isComponentActive: true,
131
272
  stateLoaded: false,
132
273
  response: '',
133
274
  submitted: false,
134
275
  updateKey: Crypto.id(),
276
+ aiFeedbackJobId: null,
277
+ aiFeedbackJob: null,
278
+ aiFeedbackResult: null,
279
+ aiFeedbackError: null,
280
+ isGeneratingFeedback: false,
281
+ feedbackPollingCancelled: false,
135
282
  }
136
283
  },
137
284
  computed: {
138
285
  ...mapGetters({
139
286
  enrollment: 'enrollment/get',
287
+ organization: 'organization/get',
140
288
  }),
289
+ aiModeEnabled() {
290
+ return !!_.get(this.block, 'metadata.config.ai_mode_for_student', false)
291
+ },
141
292
  canSubmit() {
293
+ if (this.aiModeEnabled) {
294
+ return true
295
+ }
296
+
142
297
  // Make sure the response is not empty and not equal to the starting text
143
298
  return (
144
299
  this.response &&
@@ -175,10 +330,24 @@ export default {
175
330
  if (_.isEmpty(this.block.metadata.config.starting_text)) {
176
331
  this.block.metadata.config.starting_text = ''
177
332
  }
333
+ if (!_.isBoolean(this.block.metadata.config.ai_mode_for_student)) {
334
+ this.$set(this.block.metadata.config, 'ai_mode_for_student', false)
335
+ }
336
+ },
337
+ beforeDestroy() {
338
+ this.isComponentActive = false
339
+ this.feedbackPollingCancelled = true
178
340
  },
179
341
  mounted() {},
180
342
  methods: {
181
343
  async onAfterSetContentBlockState() {
344
+ // This component's state is persisted via UserContentBlockState, so ensure
345
+ // transient lifecycle flags are reset when restoring state.
346
+ this.isComponentActive = true
347
+ this.feedbackPollingCancelled = true
348
+ this.isGeneratingFeedback = false
349
+ this.aiFeedbackError = null
350
+
182
351
  // Check to see if we have a state already for this block with the same block_id
183
352
  // States are loaded via the ContentBlock.id but in this particular case we want to
184
353
  // maintain the state ACROSS different ContentBlock.ids but with the same linked Block.id
@@ -198,6 +367,22 @@ export default {
198
367
  'metadata.submitted',
199
368
  false
200
369
  )
370
+
371
+ this.aiFeedbackJobId = _.get(
372
+ userState,
373
+ 'metadata.aiFeedbackJobId',
374
+ null
375
+ )
376
+ this.aiFeedbackJob = _.get(
377
+ userState,
378
+ 'metadata.aiFeedbackJob',
379
+ null
380
+ )
381
+ this.aiFeedbackResult = _.get(
382
+ userState,
383
+ 'metadata.aiFeedbackResult',
384
+ null
385
+ )
201
386
  }
202
387
 
203
388
  // If after the state is applied the response is still empty then apply the default response
@@ -208,14 +393,243 @@ export default {
208
393
  // If we don't have a block_id then we are in the initial setup state
209
394
  this.response = this.block.metadata.config.starting_text
210
395
  }
396
+
397
+ if (!this.aiModeEnabled) {
398
+ this.aiFeedbackJobId = null
399
+ this.aiFeedbackJob = null
400
+ this.aiFeedbackResult = null
401
+ }
402
+
211
403
  this.updateKey = Crypto.id()
212
404
  this.stateLoaded = true
405
+
406
+ if (
407
+ this.aiModeEnabled &&
408
+ this.submitted &&
409
+ this.aiFeedbackJobId &&
410
+ !this.isGeneratingFeedback
411
+ ) {
412
+ await this.resumeFeedbackJob()
413
+ }
213
414
  },
214
- onSubmit() {
415
+ async onSubmit() {
215
416
  this.submitted = true
417
+ this.aiFeedbackError = null
418
+
419
+ if (this.aiModeEnabled) {
420
+ this.aiFeedbackJobId = null
421
+ this.aiFeedbackJob = null
422
+ this.aiFeedbackResult = null
423
+ }
216
424
 
217
425
  // Force the state to save on submit and not wait
218
- this.$Tracking.flushState()
426
+ await this.$Tracking.flushState()
427
+
428
+ if (this.aiModeEnabled) {
429
+ await this.onGenerateFeedback()
430
+ }
431
+ },
432
+ onClickEdit() {
433
+ this.feedbackPollingCancelled = true
434
+ this.isGeneratingFeedback = false
435
+ this.submitted = false
436
+ },
437
+ async fetchAiFeedbackStatus(jobId) {
438
+ const enrollment = new Enrollment({ id: this.enrollment.id })
439
+ const contentBlock = new ContentBlock({ id: this.block.id })
440
+
441
+ const statusResponse = await Enrollment.params({ job_id: jobId })
442
+ .custom(
443
+ enrollment,
444
+ contentBlock,
445
+ 'open-response/ai-feedback/status'
446
+ )
447
+ .first()
448
+
449
+ this.aiFeedbackJob = _.get(statusResponse, 'job', null)
450
+ this.aiFeedbackResult = _.get(statusResponse, 'result', null)
451
+
452
+ return _.get(this.aiFeedbackJob, 'status', null)
453
+ },
454
+ async onGenerateFeedback() {
455
+ if (this.isGeneratingFeedback) {
456
+ return
457
+ }
458
+
459
+ if (!this.aiModeEnabled) {
460
+ return
461
+ }
462
+
463
+ if (!this.organization?.id) {
464
+ this.aiFeedbackError = this.$t(
465
+ 'windward.core.components.content.blocks.open_response.ai_feedback_error'
466
+ )
467
+ return
468
+ }
469
+
470
+ this.aiFeedbackError = null
471
+ this.feedbackPollingCancelled = false
472
+ this.isGeneratingFeedback = true
473
+
474
+ try {
475
+ const enrollment = new Enrollment({ id: this.enrollment.id })
476
+ const contentBlock = new ContentBlock({ id: this.block.id })
477
+
478
+ const startResponse = await Enrollment.config({
479
+ method: 'POST',
480
+ data: { response: this.response || '' },
481
+ })
482
+ .custom(enrollment, contentBlock, 'open-response/ai-feedback')
483
+ .first()
484
+
485
+ const job = _.get(startResponse, 'job', null)
486
+ const jobId = _.get(job, 'id', null)
487
+ if (!jobId) {
488
+ throw new Error('AI_FEEDBACK_MISSING_JOB_ID')
489
+ }
490
+
491
+ this.aiFeedbackJobId = jobId
492
+ this.aiFeedbackJob = job
493
+ this.aiFeedbackResult = _.get(startResponse, 'result', null)
494
+
495
+ // Persist the job id + any immediate result
496
+ await this.$Tracking.flushState()
497
+
498
+ if (this.aiFeedbackResult) {
499
+ return
500
+ }
501
+
502
+ await this.pollFeedbackJob(jobId)
503
+ } catch (error) {
504
+ if (!this.isComponentActive) {
505
+ return
506
+ }
507
+ if (error?.cancelled) {
508
+ return
509
+ }
510
+
511
+ this.aiFeedbackError = this.$t(
512
+ 'windward.core.components.content.blocks.open_response.ai_feedback_error'
513
+ )
514
+ } finally {
515
+ if (this.isComponentActive) {
516
+ this.isGeneratingFeedback = false
517
+ }
518
+ }
519
+ },
520
+ async resumeFeedbackJob() {
521
+ if (this.isGeneratingFeedback) {
522
+ return
523
+ }
524
+
525
+ const jobId = this.aiFeedbackJobId
526
+ if (!jobId || !this.organization?.id) {
527
+ return
528
+ }
529
+
530
+ this.feedbackPollingCancelled = false
531
+ this.aiFeedbackError = null
532
+ this.isGeneratingFeedback = true
533
+
534
+ try {
535
+ const status = await this.fetchAiFeedbackStatus(jobId)
536
+ if (status === 'queued' || status === 'running') {
537
+ await this.pollFeedbackJob(jobId)
538
+ }
539
+ } catch (error) {
540
+ if (!this.isComponentActive) {
541
+ return
542
+ }
543
+ if (error?.cancelled) {
544
+ return
545
+ }
546
+
547
+ this.aiFeedbackError = this.$t(
548
+ 'windward.core.components.content.blocks.open_response.ai_feedback_error'
549
+ )
550
+ } finally {
551
+ if (this.isComponentActive) {
552
+ this.isGeneratingFeedback = false
553
+ }
554
+ }
555
+ },
556
+ async pollFeedbackJob(jobId) {
557
+ // 30 minutes at 2s intervals
558
+ const maxAttempts = 900
559
+ const delayMs = 2000
560
+
561
+ let attempt = 0
562
+
563
+ while (attempt < maxAttempts) {
564
+ if (
565
+ this.feedbackPollingCancelled ||
566
+ !this.isComponentActive
567
+ ) {
568
+ const cancelled = new Error('AI_FEEDBACK_POLLING_CANCELLED')
569
+ cancelled.cancelled = true
570
+ throw cancelled
571
+ }
572
+
573
+ // eslint-disable-next-line no-await-in-loop
574
+ const status = await this.fetchAiFeedbackStatus(jobId)
575
+ if (status === 'succeeded' && this.aiFeedbackResult) {
576
+ // Persist the final result so it loads instantly later
577
+ this.isGeneratingFeedback = false
578
+ // eslint-disable-next-line no-await-in-loop
579
+ await this.$Tracking.flushState()
580
+ return
581
+ }
582
+
583
+ if (status === 'failed') {
584
+ this.aiFeedbackError = this.$t(
585
+ 'windward.core.components.content.blocks.open_response.ai_feedback_error'
586
+ )
587
+ this.isGeneratingFeedback = false
588
+ // eslint-disable-next-line no-await-in-loop
589
+ await this.$Tracking.flushState()
590
+ return
591
+ }
592
+
593
+ attempt += 1
594
+ // eslint-disable-next-line no-await-in-loop
595
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
596
+ }
597
+
598
+ this.aiFeedbackError = this.$t(
599
+ 'windward.core.components.content.blocks.open_response.ai_feedback_timeout'
600
+ )
601
+ this.isGeneratingFeedback = false
602
+ await this.$Tracking.flushState()
603
+ },
604
+ getCitationLink(pageId) {
605
+ const courseId =
606
+ _.get(this.enrollment, 'course.id', null) ||
607
+ _.get(this.enrollment, 'course_id', null)
608
+ const sectionId =
609
+ _.get(this.enrollment, 'course_section_id', null) ||
610
+ _.get(this.enrollment, 'courseSection.id', null)
611
+
612
+ if (!courseId || !sectionId || !pageId) {
613
+ return null
614
+ }
615
+
616
+ return `/course/${courseId}/section/${sectionId}/content/${pageId}`
617
+ },
618
+ getUnderstandingLevelLabel(level) {
619
+ const normalized = String(level || '')
620
+ .trim()
621
+ .toLowerCase()
622
+ if (!normalized) {
623
+ return ''
624
+ }
625
+
626
+ const key = `windward.core.components.content.blocks.open_response.understanding_levels.${normalized}`
627
+ const translated = this.$t(key)
628
+ if (translated === key) {
629
+ return _.startCase(normalized)
630
+ }
631
+
632
+ return translated
219
633
  },
220
634
  },
221
635
  }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :class="blockDirectionClasses" :dir="blockTextDirection">
3
3
  <v-container class="pa-0">
4
4
  <h2
5
5
  v-if="
@@ -35,7 +35,16 @@
35
35
  </v-btn>
36
36
  </div>
37
37
  </v-container>
38
- <v-container class="pa-0">
38
+ <v-alert
39
+ v-if="block.metadata.config.items.length === 0"
40
+ type="warning"
41
+ >{{
42
+ $t(
43
+ 'windward.core.components.content.blocks.scenario_choice.no_scenario'
44
+ )
45
+ }}</v-alert
46
+ >
47
+ <v-container v-else class="pa-0">
39
48
  <v-row
40
49
  v-for="(item, itemIndex) in block.metadata.config.items"
41
50
  :key="itemIndex"
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :class="blockDirectionClasses" :dir="blockTextDirection">
3
3
  <v-container
4
4
  v-if="
5
5
  block.metadata.config.title ||
@@ -25,8 +25,13 @@
25
25
  {{ block.metadata.config.instructions }}
26
26
  </p>
27
27
  </v-container>
28
- <v-container class="pa-0">
29
- <v-tabs dark v-model="block.metadata.config.currentTab" show-arrows>
28
+ <v-alert
29
+ v-if="block.metadata.config.items.length === 0"
30
+ type="warning"
31
+ >{{ $t('windward.core.components.settings.tab.no_tabs') }}</v-alert
32
+ >
33
+ <v-container v-else class="pa-0">
34
+ <v-tabs v-model="block.metadata.config.currentTab" dark show-arrows>
30
35
  <v-tabs-slider></v-tabs-slider>
31
36
  <v-tab
32
37
  v-for="(tab, tabIndex) in block.metadata.config.items"
@@ -96,31 +101,10 @@ export default {
96
101
  this.block.body = this.$t(
97
102
  'windward.core.shared.content_blocks.title.tab'
98
103
  )
99
- if (_.isEmpty(this.block.metadata.config.items)) {
100
- const defaultObject = {
101
- tabHeader: '',
102
- expand: false,
103
- content: '',
104
- imageAsset: {
105
- display: {
106
- width: 100,
107
- margin: '',
108
- padding: '',
109
- },
110
- hideBackground: true,
111
- },
112
- }
113
- this.block.metadata.config.items = []
114
- this.block.metadata.config.items.push(defaultObject)
115
- } else {
116
- this.block.metadata.config.items.forEach((element) => {
117
- if (element.content == null) {
118
- element.content = ''
119
- }
120
- if (element.tabHeader == null) {
121
- element.tabHeader = ''
122
- }
123
- })
104
+ if (_.isEmpty(this.block.metadata.config.title)) {
105
+ this.block.metadata.config.title = this.$t(
106
+ 'windward.core.shared.content_blocks.title.tab'
107
+ )
124
108
  }
125
109
  if (!_.isBoolean(this.block.metadata.config.display_title)) {
126
110
  this.$set(this.block.metadata.config, 'display_title', true)
@@ -128,9 +112,12 @@ export default {
128
112
  if (_.isEmpty(this.block.metadata.config.currentTab)) {
129
113
  this.block.metadata.config.currentTab = 0
130
114
  }
115
+ if (_.isEmpty(this.block.metadata.config.items)) {
116
+ this.block.metadata.config.items = []
117
+ }
131
118
  },
132
119
  methods: {
133
- async onBeforeSave() {
120
+ onBeforeSave() {
134
121
  this.block.metadata.config.items.forEach((element) => {
135
122
  element.expand = false
136
123
  })