@windward/core 0.28.0 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Release [0.30.0] - 2026-03-26
4
+
5
+ * Merged in feature/LE-2286-file-submission-block-submit-but (pull request #498)
6
+ * Merged in feature/LE-2277/generate-case-studies (pull request #484)
7
+ * Merged in bugfix/LE-2332-tab-text-color-in-dark-mode-with (pull request #497)
8
+ * Merged in feature/LE-2286-file-submission-block-submit-but (pull request #492)
9
+ * Merged in feature/LE-2321-increase-glossary-term-character (pull request #494)
10
+ * Merged in bugfix/LE-2332-tab-text-color-in-dark-mode-with (pull request #493)
11
+ * Merge remote-tracking branch 'origin/release/0.30.0' into feature/LE-2277/generate-case-studies
12
+ * Merged release/0.29.0 into feature/LE-2286-file-submission-block-submit-but
13
+ * Merged release/0.29.0 into feature/LE-2286-file-submission-block-submit-but
14
+
15
+
16
+ ## Release [0.29.0] - 2026-03-11
17
+
18
+ * Merged in feature/LE-2319-preselect-cc-transcript-language (pull request #491)
19
+ * Merged in bugfix/LE-2292-clickable-icon-instructions-fiel (pull request #490)
20
+ * Merged release/0.29.0 into bugfix/LE-2292-clickable-icon-instructions-fiel
21
+ * Merged in feature/LE-2076-matching-game-allow-rich-text-ed (pull request #489)
22
+ * Merged in bugfix/LE-2292-clickable-icon-instructions-fiel (pull request #486)
23
+
24
+
3
25
  ## Release [0.28.0] - 2026-02-18
4
26
 
5
27
  * Merged in feature/LE-2250/open-response-feedback-2 (pull request #485)
@@ -237,9 +237,6 @@ export default {
237
237
  )
238
238
  if (_.isEmpty(this.block.metadata.config.items)) {
239
239
  this.block.metadata.config.items = []
240
- this.block.metadata.config.description = this.$t(
241
- 'windward.core.components.settings.clickable_icon.information'
242
- )
243
240
  }
244
241
  if (_.isEmpty(this.block.metadata.config.title)) {
245
242
  this.block.metadata.config.title = this.$t(
@@ -249,9 +246,6 @@ export default {
249
246
  if (!_.isBoolean(this.block.metadata.config.display_title)) {
250
247
  this.$set(this.block.metadata.config, 'display_title', true)
251
248
  }
252
- if (_.isEmpty(this.block.metadata.config.description)) {
253
- this.block.metadata.config.description = ''
254
- }
255
249
  if (_.isEmpty(this.block.metadata.config.display)) {
256
250
  this.block.metadata.config.display = {
257
251
  show_title: true,
@@ -37,11 +37,13 @@
37
37
  v-for="(tab, tabIndex) in block.metadata.config.items"
38
38
  :key="tabIndex"
39
39
  >
40
- {{
41
- tab.tabHeader === '' || tab.tabHeader === null
42
- ? 'Item ' + (tabIndex + 1)
43
- : tab.tabHeader
44
- }}
40
+ <div class="tab-headers">
41
+ {{
42
+ tab.tabHeader === '' || tab.tabHeader === null
43
+ ? 'Item ' + (tabIndex + 1)
44
+ : tab.tabHeader
45
+ }}
46
+ </div>
45
47
  </v-tab>
46
48
  <v-tab-item
47
49
  v-for="(tabContent, tabContentIndex) in block.metadata
@@ -55,6 +57,7 @@
55
57
  <TextViewer
56
58
  v-if="!tabContent.expand"
57
59
  v-model="tabContent.content"
60
+ class="text-viewer"
58
61
  text-viewer
59
62
  ></TextViewer>
60
63
  <TextEditor
@@ -183,6 +186,9 @@ export default {
183
186
 
184
187
  <style scoped>
185
188
  .text-viewer {
186
- color: var(--v-primary-base);
189
+ color: var(--v-primary-base) !important;
190
+ }
191
+ .tab-headers {
192
+ color: white !important;
187
193
  }
188
194
  </style>
@@ -3,18 +3,16 @@
3
3
  <v-card-title
4
4
  >{{
5
5
  $t(
6
- 'windward.core.components.content.blocks.user_upload.user_uploads'
6
+ 'windward.core.components.content.blocks.user_upload.review_current_uploads'
7
7
  )
8
8
  }}
9
- -
10
- {{ $d(new Date(userFileAsset.created_at), 'long') }}
11
9
  </v-card-title>
12
10
  <v-card-text>
13
- <v-simple-table>
11
+ <v-simple-table class="simple-table" elevation="1">
14
12
  <template #default>
15
13
  <thead>
16
14
  <tr>
17
- <th>
15
+ <th style="width: 60%">
18
16
  {{ $t('shared.file.name') }}
19
17
  </th>
20
18
  <th>
@@ -73,14 +71,24 @@
73
71
  </tbody>
74
72
  </template>
75
73
  </v-simple-table>
74
+ <v-col class="d-flex justify-center">
75
+ <v-btn
76
+ color="success"
77
+ elevation="0"
78
+ class="text-center"
79
+ @click="onSubmit"
80
+ >
81
+ {{ $t('shared.forms.submit') }}
82
+ </v-btn>
83
+ </v-col>
76
84
  </v-card-text>
77
85
  </v-card>
78
86
  </template>
79
87
 
80
88
  <script>
89
+ import UserFileAsset from '../../../../models/UserFileAsset'
81
90
  import Download from '~/helpers/Download'
82
91
 
83
- import UserFileAsset from '../../../../models/UserFileAsset'
84
92
  import FileAsset from '~/models/FileAsset'
85
93
 
86
94
  import ContentBlock from '~/models/ContentBlock'
@@ -114,6 +122,9 @@ export default {
114
122
  this.userFileAsset = this.value
115
123
  },
116
124
  methods: {
125
+ onSubmit() {
126
+ this.$emit('submit')
127
+ },
117
128
  onConfirmDelete(file) {
118
129
  const self = this
119
130
  this.$toast.info(this.$t('shared.forms.confirm_delete_text'), {
@@ -0,0 +1,161 @@
1
+ <template>
2
+ <div v-if="userFileAsset.length">
3
+ <h5 class="mb-2">
4
+ {{
5
+ $t(
6
+ 'windward.core.components.content.blocks.user_upload.submission_history'
7
+ )
8
+ }}
9
+ </h5>
10
+ <v-card v-for="(fileSubmission, index) in userFileAsset" :key="index">
11
+ <v-card-title class="pb-0">
12
+ <p class="mb-0">
13
+ {{
14
+ $t(
15
+ 'windward.core.components.content.blocks.user_upload.submission'
16
+ )
17
+ }}
18
+ -
19
+ {{ $d(new Date(fileSubmission.created_at), 'long') }}
20
+ </p>
21
+ </v-card-title>
22
+ <v-card-text>
23
+ <v-simple-table>
24
+ <thead>
25
+ <tr>
26
+ <th style="width: 60%">
27
+ {{
28
+ $t(
29
+ 'windward.core.components.content.blocks.user_upload.filename'
30
+ )
31
+ }}
32
+ </th>
33
+ <th style="width: 20%">
34
+ {{
35
+ $t(
36
+ 'windward.core.components.content.blocks.user_upload.file_size'
37
+ )
38
+ }}
39
+ </th>
40
+ <th style="width: 20%">
41
+ {{
42
+ $t(
43
+ 'windward.core.components.content.blocks.user_upload.download'
44
+ )
45
+ }}
46
+ </th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <tr
51
+ v-for="file in fileSubmission.file_assets"
52
+ :key="file.id"
53
+ >
54
+ <td>{{ file.name }}</td>
55
+ <td>
56
+ {{ file.asset.metadata.size | humanFilesize }}
57
+ </td>
58
+ <td>
59
+ <v-btn
60
+ link
61
+ elevation="0"
62
+ @click="
63
+ Download.url(
64
+ file.asset.public_url,
65
+ file.name
66
+ )
67
+ "
68
+ >
69
+ <v-icon>mdi-download</v-icon>
70
+ <span class="sr-only">
71
+ {{ $t('shared.file.download') }}
72
+ </span>
73
+ </v-btn>
74
+ </td>
75
+ </tr>
76
+ </tbody>
77
+ </v-simple-table>
78
+ </v-card-text>
79
+ </v-card>
80
+ </div>
81
+ </template>
82
+
83
+ <script>
84
+ import UserFileAsset from '../../../../models/UserFileAsset'
85
+ import Download from '~/helpers/Download'
86
+
87
+ import FileAsset from '~/models/FileAsset'
88
+
89
+ import ContentBlock from '~/models/ContentBlock'
90
+ import Enrollment from '~/models/Enrollment'
91
+
92
+ export default {
93
+ name: 'DisplayUserFilesTable',
94
+ components: {},
95
+ props: {
96
+ value: {
97
+ type: Array,
98
+ required: false,
99
+ default: () => {
100
+ return []
101
+ },
102
+ },
103
+ },
104
+ data() {
105
+ return {
106
+ Download,
107
+ userFileAsset: {},
108
+ }
109
+ },
110
+ watch: {
111
+ value(newVal) {
112
+ this.userFileAsset = newVal
113
+ },
114
+ },
115
+ mounted() {
116
+ this.userFileAsset = this.value
117
+ },
118
+ methods: {
119
+ onConfirmDelete(file) {
120
+ const self = this
121
+ this.$toast.info(this.$t('shared.forms.confirm_delete_text'), {
122
+ duration: null,
123
+ action: [
124
+ {
125
+ text: this.$t('shared.forms.cancel'),
126
+ onClick: (e, toastObject) => {
127
+ toastObject.goAway(0)
128
+ },
129
+ },
130
+ {
131
+ text: this.$t('shared.forms.confirm'),
132
+ onClick: (e, toastObject) => {
133
+ toastObject.goAway(0)
134
+ self.deleteFile(file)
135
+ },
136
+ },
137
+ ],
138
+ })
139
+ },
140
+ async deleteFile(file) {
141
+ await new FileAsset({ id: file.file_asset_id })
142
+ .for(
143
+ new Enrollment(this.enrollment),
144
+ new ContentBlock({
145
+ id: this.userFileAsset.content_block_id,
146
+ }),
147
+ new UserFileAsset({ id: this.userFileAsset.id })
148
+ )
149
+ .delete()
150
+
151
+ // Remove the deleted file from the array for display reasons
152
+ this.userFileAsset.file_assets =
153
+ this.userFileAsset.file_assets.filter(function (f) {
154
+ return f.file_asset_id !== file.file_asset_id
155
+ })
156
+
157
+ this.$emit('input', this.userFileAsset)
158
+ },
159
+ },
160
+ }
161
+ </script>
@@ -1,109 +1,104 @@
1
1
  <template>
2
- <v-container :class="['pa-0', blockDirectionClasses]" :dir="blockTextDirection">
3
- <div>
4
- <h2
5
- v-if="
6
- block.metadata.config.title &&
7
- block.metadata.config.display_title
8
- "
9
- tabindex="0"
10
- >
11
- {{ block.metadata.config.title }}
12
- </h2>
13
- <v-row>
14
- <v-col v-if="block.metadata.config.instructions" cols="12">
15
- <p tabindex="0" class="pt-3">
16
- {{ block.metadata.config.instructions }}
2
+ <v-container
3
+ :class="['pa-0', blockDirectionClasses]"
4
+ :dir="blockTextDirection"
5
+ >
6
+ <h2
7
+ v-if="
8
+ block.metadata.config.title &&
9
+ block.metadata.config.display_title
10
+ "
11
+ tabindex="0"
12
+ >
13
+ {{ block.metadata.config.title }}
14
+ </h2>
15
+ <v-row>
16
+ <v-col v-if="block.metadata.config.instructions" cols="12">
17
+ <p tabindex="0" class="pt-3">
18
+ {{ block.metadata.config.instructions }}
19
+ </p>
20
+ </v-col>
21
+ <v-col v-if="!blockExists" cols="12">
22
+ <v-alert type="warning">
23
+ <p>
24
+ {{
25
+ $t(
26
+ 'windward.core.components.content.blocks.user_upload.must_save'
27
+ )
28
+ }}
17
29
  </p>
18
- </v-col>
19
- <v-col v-if="!blockExists" cols="12">
20
- <v-alert type="warning">
21
- <p>
22
- {{
23
- $t(
24
- 'windward.core.components.content.blocks.user_upload.must_save'
25
- )
26
- }}
27
- </p>
28
- </v-alert>
29
- </v-col>
30
- </v-row>
31
- <v-row v-if="render">
32
- <v-col cols="12">
33
- <div class="upload-container" :class="uploadContainerClass">
34
- <DisplayUserFilesTable
35
- v-model="userFileAsset"
36
- :enrollment="enrollment"
37
- ></DisplayUserFilesTable>
38
- <div v-if="showUpload">
39
- <div v-if="blockExists">
40
- <v-form
41
- ref="form"
42
- v-model="valid"
43
- lazy-validation
44
- class="pb-0"
45
- >
46
- <FileDropZone
47
- v-model="uploadFiles"
48
- :accept="
49
- block.metadata.config.uploadSettings
50
- .accept
51
- "
52
- :multiple="
53
- block.metadata.config.uploadSettings
54
- .multiple
55
- "
56
- ></FileDropZone>
57
-
58
- <v-container class="text-center">
59
- <v-btn
60
- :disabled="!canUpload || loading"
61
- color="primary"
62
- elevation="0"
63
- class="text-center"
64
- @click="handleUpload"
65
- >
66
- {{ $t('shared.forms.upload') }}
67
- </v-btn>
68
- </v-container>
69
- </v-form>
70
- </div>
71
- </div>
72
- </div>
73
- </v-col>
74
- </v-row>
75
- </div>
30
+ </v-alert>
31
+ </v-col>
32
+ </v-row>
33
+ <v-row v-if="render">
34
+ <v-col v-if="blockExists" cols="12">
35
+ <div class="upload-container" :class="uploadContainerClass">
36
+ <v-col>
37
+ <FileDropZone
38
+ v-model="uploadFiles"
39
+ :accept="
40
+ block.metadata.config.uploadSettings.accept
41
+ "
42
+ :multiple="
43
+ block.metadata.config.uploadSettings.multiple
44
+ "
45
+ ></FileDropZone>
46
+ <v-col class="d-flex justify-center">
47
+ <v-btn
48
+ :disabled="!canUpload || loading"
49
+ color="primary"
50
+ elevation="0"
51
+ class="text-center"
52
+ @click="handleUpload"
53
+ >
54
+ {{ $t('shared.forms.upload') }}
55
+ </v-btn>
56
+ </v-col>
57
+ </v-col>
58
+ <DisplayUserFilesTable
59
+ v-model="uploadedUserFileAsset"
60
+ :enrollment="enrollment"
61
+ @submit="handleSubmit"
62
+ ></DisplayUserFilesTable>
63
+ <v-divider class="my-4"></v-divider>
64
+ <SubmittedDisplayUserFilesTable
65
+ v-model="submittedUserFileAssets"
66
+ ></SubmittedDisplayUserFilesTable>
67
+ </div>
68
+ </v-col>
69
+ </v-row>
76
70
  </v-container>
77
71
  </template>
78
72
 
79
73
  <script>
80
74
  import _ from 'lodash'
81
75
  import { mapGetters } from 'vuex'
76
+ import UserFileAsset from '../../../models/UserFileAsset'
77
+ import DisplayUserFilesTable from './UserUpload/DisplayUserFilesTable.vue'
78
+ import SubmittedDisplayUserFilesTable from './UserUpload/SubmittedDisplayUserFilesTable.vue'
82
79
  import Uuid from '~/helpers/Uuid'
83
80
  import Download from '~/helpers/Download'
84
81
  import Enrollment from '~/models/Enrollment'
85
82
  import ContentBlock from '~/models/ContentBlock'
86
83
  import BaseContentBlock from '~/components/Content/Blocks/BaseContentBlock'
87
84
  import FileDropZone from '~/components/Core/FileDropZone.vue'
88
- import UserFileAsset from '../../../models/UserFileAsset'
89
-
90
- import DisplayUserFilesTable from './UserUpload/DisplayUserFilesTable.vue'
91
85
 
92
86
  export default {
93
87
  name: 'UserUpload',
94
88
  components: {
95
89
  FileDropZone,
96
90
  DisplayUserFilesTable,
91
+ SubmittedDisplayUserFilesTable,
97
92
  },
98
93
  extends: BaseContentBlock,
99
94
  data() {
100
95
  return {
101
96
  Download,
102
97
  saveState: false, // Override the base block to disable state saving
103
- valid: true,
104
98
  loading: false,
105
99
  uploadFiles: [],
106
- userFileAsset: {},
100
+ uploadedUserFileAsset: {},
101
+ submittedUserFileAssets: [],
107
102
  studentUpload: null,
108
103
  maxFileLimit: 10,
109
104
  }
@@ -121,44 +116,48 @@ export default {
121
116
  return Uuid.test(this.block.id)
122
117
  },
123
118
  canUpload() {
124
- if (this.valid) {
125
- const currentFiles = _.get(
126
- this.userFileAsset,
127
- 'file_assets',
128
- []
129
- )
119
+ const currentFiles = _.get(
120
+ this.uploadedUserFileAsset,
121
+ 'file_assets',
122
+ []
123
+ )
130
124
 
131
- // Multiple disabled, single file selected
132
- if (
133
- !Array.isArray(this.uploadFiles) &&
134
- !this.block.metadata.config.uploadSettings.multiple &&
135
- this.uploadFiles &&
136
- currentFiles.length === 0
137
- ) {
138
- return true
139
- } else if (
140
- // Multi enabled, one or more files selected
141
- this.block.metadata.config.uploadSettings.multiple &&
142
- Array.isArray(this.uploadFiles) &&
143
- this.uploadFiles.length > 0 &&
144
- this.uploadFiles.length + currentFiles.length <=
145
- this.maxFileLimit
146
- ) {
147
- return true
148
- } else {
149
- // No files
150
- return false
151
- }
125
+ // Multiple disabled, single file selected
126
+ if (
127
+ !Array.isArray(this.uploadFiles) &&
128
+ !this.block.metadata.config.uploadSettings.multiple &&
129
+ this.uploadFiles &&
130
+ currentFiles.length === 0
131
+ ) {
132
+ return true
133
+ } else if (
134
+ // Multi enabled, one or more files selected
135
+ this.block.metadata.config.uploadSettings.multiple &&
136
+ Array.isArray(this.uploadFiles) &&
137
+ this.uploadFiles.length > 0 &&
138
+ this.uploadFiles.length + currentFiles.length <=
139
+ this.maxFileLimit
140
+ ) {
141
+ return true
142
+ } else {
143
+ // No files
144
+ return false
152
145
  }
153
- return false
146
+ },
147
+ canSubmit() {
148
+ return !this.canUpload && !_.isEmpty(this.uploadedUserFileAsset)
154
149
  },
155
150
  showUpload() {
156
- const currentFiles = _.get(this.userFileAsset, 'file_assets', [])
157
-
151
+ const currentFiles = _.get(
152
+ this.uploadedUserFileAsset,
153
+ 'file_assets',
154
+ []
155
+ )
158
156
  // Multiple disabled, confirm there's no current files
159
157
  if (
160
158
  !this.block.metadata.config.uploadSettings.multiple &&
161
- currentFiles.length === 0
159
+ currentFiles.length === 0 &&
160
+ this.submittedUserFileAssets.length === 0
162
161
  ) {
163
162
  return true
164
163
  } else if (
@@ -223,7 +222,6 @@ export default {
223
222
  ) {
224
223
  uploadFiles = [uploadFiles]
225
224
  }
226
-
227
225
  // Create a new UserFileAsset if we don't have an instance yet
228
226
  let userFileAssetRequest = new UserFileAsset({
229
227
  file: uploadFiles,
@@ -233,31 +231,66 @@ export default {
233
231
  )
234
232
 
235
233
  // Apply the existing id if we already have an instance
236
- if (Uuid.test(this.userFileAsset.id)) {
237
- userFileAssetRequest.id = this.userFileAsset.id
234
+ if (Uuid.test(this.uploadedUserFileAsset.id)) {
235
+ userFileAssetRequest.id = this.uploadedUserFileAsset.id
238
236
  }
239
237
 
240
238
  // Add our files to upload
241
239
  userFileAssetRequest.file = uploadFiles
242
240
 
243
241
  try {
244
- // Apply the response back to the reactive this.userFileAsset
245
- this.userFileAsset = await userFileAssetRequest.save()
242
+ // Apply the response back to the reactive this.uploadedUserFileAsset
243
+ this.uploadedUserFileAsset = await userFileAssetRequest.save()
246
244
  } catch (e) {
247
245
  this.$toast.error(this.$t('shared.forms.errors.unknown'))
248
- console.log(e)
246
+ console.error(e)
249
247
  }
250
248
  this.loading = false
251
249
  this.uploadFiles = []
252
250
  },
251
+ async handleSubmit() {
252
+ if (this.canSubmit) {
253
+ this.loading = true
254
+
255
+ // Create an update request for the existing UserFileAsset
256
+ const userFileAssetRequest = new UserFileAsset({
257
+ status: 'submitted',
258
+ }).for(
259
+ new Enrollment(this.enrollment),
260
+ new ContentBlock({ id: this.block.id })
261
+ )
262
+
263
+ // Apply the existing id to update the specific record
264
+ if (Uuid.test(this.uploadedUserFileAsset.id)) {
265
+ userFileAssetRequest.id = this.uploadedUserFileAsset.id
266
+ }
267
+ try {
268
+ // Save the update
269
+ const response = await userFileAssetRequest.save()
270
+ this.submittedUserFileAssets.unshift(response)
271
+ this.uploadedUserFileAsset = {}
272
+ } catch (e) {
273
+ this.$toast.error(this.$t('shared.forms.errors.unknown'))
274
+ console.error(e)
275
+ }
276
+ this.loading = false
277
+ }
278
+ },
253
279
  async loadUserUploads() {
254
280
  if (this.enrollment && this.block.id) {
255
- this.userFileAsset = await new UserFileAsset()
281
+ const result = await new UserFileAsset()
256
282
  .for(
257
283
  new Enrollment(this.enrollment),
258
284
  new ContentBlock({ id: this.block.id })
259
285
  )
260
- .first()
286
+ .get()
287
+ result.forEach((file) => {
288
+ if (file.status === 'submitted') {
289
+ this.submittedUserFileAssets.push(file)
290
+ } else {
291
+ this.uploadedUserFileAsset = file
292
+ }
293
+ })
261
294
  }
262
295
  },
263
296
  },
@@ -439,14 +439,15 @@ export default {
439
439
  }
440
440
  }
441
441
 
442
- // 4. Sort tracks: course source language first
442
+ // 4. Sort tracks: current course locale first (target language on translated
443
+ // courses, source language on non-translated courses).
443
444
  allTracks.sort((a, b) => {
444
- if (a.srclang === this.courseSourceLocale) return -1
445
- if (b.srclang === this.courseSourceLocale) return 1
445
+ if (this.srclangMatchesLocale(a.srclang, this.courseCurrentLocale)) return -1
446
+ if (this.srclangMatchesLocale(b.srclang, this.courseCurrentLocale)) return 1
446
447
  return 0
447
448
  })
448
449
 
449
- // 5. Set default on first track (should be source language)
450
+ // 5. Set default on first track (target language for translated courses)
450
451
  if (allTracks.length > 0) {
451
452
  allTracks[0].default = true
452
453
  }
@@ -766,10 +767,25 @@ export default {
766
767
  const langBase = langLower.split('-')[0]
767
768
 
768
769
  // Check if the full language code or its base is in allowedCaptionLocales
769
- return this.allowedCaptionLocales.has(langLower) ||
770
+ return this.allowedCaptionLocales.has(langLower) ||
770
771
  this.allowedCaptionLocales.has(langBase)
771
772
  },
772
773
 
774
+ /**
775
+ * Check whether a srclang code refers to the same language as a locale code.
776
+ * Handles case differences ("PT-BR" vs "pt-br") and DeepL short codes
777
+ * ("ES" matching "es-ES").
778
+ * @param {string} srclang - Track srclang (e.g. "PT-BR", "ES")
779
+ * @param {string} locale - Locale code to match against (e.g. "pt-br", "es-es")
780
+ * @returns {boolean}
781
+ */
782
+ srclangMatchesLocale(srclang, locale) {
783
+ if (!srclang || !locale) return false
784
+ const a = srclang.toLowerCase()
785
+ const b = locale.toLowerCase()
786
+ return a === b || a.split('-')[0] === b.split('-')[0]
787
+ },
788
+
773
789
  /**
774
790
  * Check if the given text has words, omitting HTML tags and HTML entities
775
791
  * @param {string} text - The text to check
@@ -300,10 +300,15 @@ export default {
300
300
  'windward.core.components.settings.clickable_icon.clickable_icon_title'
301
301
  )
302
302
  }
303
- if (_.isEmpty(this.block.metadata.config.instructions)) {
303
+ if (
304
+ _.isEmpty(this.block.metadata.config.instructions) &&
305
+ !this.block.metadata.config.__isInitialized
306
+ ) {
304
307
  this.block.metadata.config.instructions = this.$t(
305
308
  'windward.core.components.settings.clickable_icon.instructions'
306
309
  )
310
+ // save state of initialization so we can allow user to set inputs to empty
311
+ this.$set(this.block.metadata.config, '__isInitialized', true)
307
312
  }
308
313
  if (!_.isBoolean(this.block.metadata.config.display_title)) {
309
314
  this.$set(this.block.metadata.config, 'display_title', true)
@@ -49,13 +49,63 @@
49
49
  v-bind="attrs"
50
50
  type=" table-row-divider, list-item, divider, list-item, divider, image,image"
51
51
  ></v-skeleton-loader>
52
+
53
+ <div v-if="isTextBlock" class="case-study-controls mt-2">
54
+ <div class="text-caption mb-2">
55
+ {{
56
+ $t(
57
+ 'windward.core.components.settings.text_editor.case_study_help'
58
+ )
59
+ }}
60
+ </div>
61
+ <v-autocomplete
62
+ v-model="selectedCaseStudyPages"
63
+ class="mb-2 case-study-page-selector"
64
+ :items="flattenedContent"
65
+ outlined
66
+ hide-details
67
+ multiple
68
+ chips
69
+ small-chips
70
+ deletable-chips
71
+ :disabled="render || isGeneratingCaseStudy"
72
+ :label="
73
+ $t(
74
+ 'windward.core.components.settings.text_editor.case_study_selected_pages'
75
+ )
76
+ "
77
+ item-text="content.name"
78
+ return-object
79
+ ></v-autocomplete>
80
+ <v-btn
81
+ elevation="0"
82
+ color="secondary"
83
+ block
84
+ :loading="isGeneratingCaseStudy"
85
+ :disabled="render || isGeneratingCaseStudy"
86
+ @click="onGenerateCaseStudy"
87
+ >
88
+ <v-icon v-if="!isGeneratingCaseStudy" class="pr-1">
89
+ mdi-magic-staff
90
+ </v-icon>
91
+ <span v-if="!isGeneratingCaseStudy">{{
92
+ $t(
93
+ 'windward.core.components.settings.text_editor.generate_case_study'
94
+ )
95
+ }}</span>
96
+ </v-btn>
97
+ </div>
52
98
  </div>
53
99
  </template>
54
100
 
55
101
  <script>
102
+ import _ from 'lodash'
103
+ import { mapGetters } from 'vuex'
56
104
  import Crypto from '~/helpers/Crypto'
57
105
  import BaseContentSettings from '~/components/Content/Settings/BaseContentSettings.js'
58
106
  import TextEditor from '~/components/Text/TextEditor'
107
+ import Course from '~/models/Course'
108
+ import Organization from '~/models/Organization'
59
109
  export default {
60
110
  name: 'TextEditorSettings',
61
111
  components: {
@@ -76,10 +126,42 @@ export default {
76
126
  },
77
127
  hideTextEditor: false,
78
128
  updateKey: Crypto.id(),
129
+ isGeneratingCaseStudy: false,
130
+ selectedCaseStudyPages: [],
79
131
  }
80
132
  },
133
+ computed: {
134
+ ...mapGetters({
135
+ organization: 'organization/get',
136
+ course: 'course/get',
137
+ currentContent: 'content/get',
138
+ }),
139
+ isTextBlock() {
140
+ return _.get(this.block, 'tag', '') === 'content-blocks-text'
141
+ },
142
+ flattenedContent() {
143
+ const flatTree = this.$ContentService.getFlatTree()
144
+
145
+ const homepage = this.$ContentService.getHomepage()
146
+ if (!_.isEmpty(homepage)) {
147
+ flatTree.unshift(homepage)
148
+ }
149
+
150
+ return flatTree
151
+ },
152
+ },
81
153
  mounted() {
82
154
  this.setConfig({ expand: false })
155
+
156
+ // Default selected pages to the current page
157
+ if (
158
+ this.isTextBlock &&
159
+ Array.isArray(this.selectedCaseStudyPages) &&
160
+ this.selectedCaseStudyPages.length === 0 &&
161
+ !_.isEmpty(this.currentContent)
162
+ ) {
163
+ this.selectedCaseStudyPages = [_.cloneDeep(this.currentContent)]
164
+ }
83
165
  },
84
166
  methods: {
85
167
  onExpand() {
@@ -87,8 +169,169 @@ export default {
87
169
  this.block.metadata.config.expand = this.hideTextEditor
88
170
  this.updateKey = Crypto.id()
89
171
  },
172
+ blockHasMeaningfulText() {
173
+ const html = _.get(this.block, 'body', '') || ''
174
+ if (!html) {
175
+ return false
176
+ }
177
+ // Strip tags and check for meaningful length
178
+ const text = String(html).replace(/<[^>]*>/g, '').trim()
179
+ return text.length > 0
180
+ },
181
+ async requestCaseStudyGeneration(contentIds) {
182
+ const organizationId = _.get(this.organization, 'id', null)
183
+ const courseId = _.get(this.course, 'id', null)
184
+
185
+ if (!organizationId || !courseId) {
186
+ throw new Error('missing_context')
187
+ }
188
+
189
+ const request = new Course()
190
+ request.custom(
191
+ new Organization({ id: organizationId }),
192
+ new Course({ id: courseId }),
193
+ 'llm-case-study'
194
+ )
195
+
196
+ const resourcePath = request._customResource
197
+ if (!resourcePath) {
198
+ throw new Error('missing_resource')
199
+ }
200
+
201
+ const payload = {
202
+ content_ids: contentIds,
203
+ language: this.$i18n?.locale,
204
+ }
205
+
206
+ const requestConfig = request._reqConfig(
207
+ {
208
+ method: 'POST',
209
+ url: `${request.baseURL()}/${resourcePath}`,
210
+ data: payload,
211
+ },
212
+ { forceMethod: true }
213
+ )
214
+
215
+ const response = await request.request(requestConfig)
216
+ return response?.data ?? response
217
+ },
218
+ onGenerateCaseStudy(force = false) {
219
+ if (this.isGeneratingCaseStudy || this.render) {
220
+ return
221
+ }
222
+
223
+ const contentIds = (this.selectedCaseStudyPages || [])
224
+ .map((c) => _.get(c, 'id', null))
225
+ .filter((id) => typeof id === 'string' && id.length > 0)
226
+
227
+ if (contentIds.length === 0) {
228
+ this.$toast?.error(
229
+ this.$t(
230
+ 'windward.core.components.settings.text_editor.case_study_select_pages_error'
231
+ )
232
+ )
233
+ return
234
+ }
235
+
236
+ if (!force && this.blockHasMeaningfulText()) {
237
+ const confirmText = this.$t(
238
+ 'windward.core.components.settings.text_editor.case_study_replace_confirm'
239
+ )
240
+ this.$dialog.show(confirmText, {
241
+ icon: 'mdi-alert-outline',
242
+ duration: null,
243
+ action: [
244
+ {
245
+ text: this.$t('shared.forms.cancel'),
246
+ onClick: (_e, toastObject) => {
247
+ toastObject.goAway(0)
248
+ },
249
+ },
250
+ {
251
+ text: this.$t('shared.forms.confirm'),
252
+ onClick: (_e, toastObject) => {
253
+ toastObject.goAway(0)
254
+ this.onGenerateCaseStudy(true)
255
+ },
256
+ },
257
+ ],
258
+ })
259
+ return
260
+ }
261
+
262
+ this.isGeneratingCaseStudy = true
263
+
264
+ this.requestCaseStudyGeneration(contentIds)
265
+ .then((data) => {
266
+ if (!data || typeof data.html !== 'string') {
267
+ throw new Error('invalid_response')
268
+ }
269
+
270
+ this.block.body = data.html
271
+ this.updateKey = Crypto.id()
272
+
273
+ this.$toast?.success(
274
+ this.$t(
275
+ 'windward.core.components.settings.text_editor.case_study_generated'
276
+ )
277
+ )
278
+ })
279
+ .catch((error) => {
280
+ // eslint-disable-next-line no-console
281
+ console.error('Case study generation failed', error)
282
+
283
+ const messageKey = _.get(
284
+ error,
285
+ 'response.data.error.message',
286
+ ''
287
+ )
288
+ const details = _.get(
289
+ error,
290
+ 'response.data.error.details',
291
+ {}
292
+ )
293
+
294
+ const errorSubtype = _.get(details, 'error_subtype', '')
295
+ const errorType = String(messageKey).split('.').pop()
296
+
297
+ if (errorSubtype === 'CHARACTER_LIMIT_EXCEEDED') {
298
+ this.$toast?.error(
299
+ this.$t(
300
+ 'windward.core.components.settings.text_editor.case_study_character_limit'
301
+ )
302
+ )
303
+ } else if (
304
+ _.get(details, 'error_type', '') ===
305
+ 'INSUFFICIENT_CONTENT' ||
306
+ errorType === 'insufficient_content'
307
+ ) {
308
+ this.$toast?.error(
309
+ this.$t(
310
+ 'windward.core.components.settings.text_editor.case_study_insufficient_content'
311
+ )
312
+ )
313
+ } else {
314
+ this.$toast?.error(
315
+ this.$t(
316
+ 'windward.core.components.settings.text_editor.case_study_error'
317
+ )
318
+ )
319
+ }
320
+ })
321
+ .finally(() => {
322
+ this.isGeneratingCaseStudy = false
323
+ })
324
+ },
90
325
  },
91
326
  }
92
327
  </script>
93
328
 
94
- <style scoped></style>
329
+ <style scoped>
330
+ .case-study-controls {
331
+ max-width: 100%;
332
+ }
333
+
334
+ .case-study-page-selector >>> .v-select__selections {
335
+ padding-top: 8px;
336
+ }
337
+ </style>
@@ -180,7 +180,7 @@ export default {
180
180
  !Uuid.test(this.block.id)
181
181
  ) {
182
182
  this.block.metadata.config.instructions = this.$t(
183
- 'windward.core.components.settings.user_upload.instructions'
183
+ 'windward.core.components.content.blocks.user_upload.instructions'
184
184
  )
185
185
  }
186
186
  if (_.isEmpty(this.block.metadata.config.uploadSettings)) {
@@ -156,6 +156,7 @@ export default {
156
156
  showGlossary: { type: Boolean, required: false, default: false },
157
157
  render: { type: Boolean, required: false, default: false },
158
158
  hideTextEditor: { type: Boolean, required: false, default: false },
159
+ defaultAlignment: { type: String, required: false, default: null },
159
160
  },
160
161
  data() {
161
162
  return {
@@ -165,7 +166,13 @@ export default {
165
166
  paused: false,
166
167
  isRevising: false,
167
168
  rephraseToneIndex: 0,
168
- toneSequence: ['neutral', 'conversational', 'formal', 'succinct', 'encouraging'],
169
+ toneSequence: [
170
+ 'neutral',
171
+ 'conversational',
172
+ 'formal',
173
+ 'succinct',
174
+ 'encouraging',
175
+ ],
169
176
  }
170
177
  },
171
178
 
@@ -356,6 +363,15 @@ export default {
356
363
  value: 'windward-table-subject-report',
357
364
  },
358
365
  ],
366
+ init_instance_callback: (editor) => {
367
+ if (this.defaultAlignment) {
368
+ editor.execCommand(
369
+ 'mceToggleFormat',
370
+ false,
371
+ this.defaultAlignment
372
+ )
373
+ }
374
+ },
359
375
  setup: () => {
360
376
  // Here we can add plugin
361
377
  getTinymce().PluginManager.add(
@@ -579,9 +595,10 @@ export default {
579
595
  return null
580
596
  }
581
597
 
582
- const tone = this.toneSequence[
583
- this.rephraseToneIndex % this.toneSequence.length
584
- ]
598
+ const tone =
599
+ this.toneSequence[
600
+ this.rephraseToneIndex % this.toneSequence.length
601
+ ]
585
602
  this.rephraseToneIndex =
586
603
  (this.rephraseToneIndex + 1) % this.toneSequence.length
587
604
 
@@ -748,12 +765,12 @@ export default {
748
765
  }
749
766
 
750
767
  // Wrap response with temporary markers so we can reselect inserted content
751
- const startId = `ww-revise-start-${this.seed}-${Date.now()}-${Math.random()
752
- .toString(36)
753
- .slice(2)}`
754
- const endId = `ww-revise-end-${this.seed}-${Date.now()}-${Math.random()
755
- .toString(36)
756
- .slice(2)}`
768
+ const startId = `ww-revise-start-${
769
+ this.seed
770
+ }-${Date.now()}-${Math.random().toString(36).slice(2)}`
771
+ const endId = `ww-revise-end-${
772
+ this.seed
773
+ }-${Date.now()}-${Math.random().toString(36).slice(2)}`
757
774
  const wrappedHtml =
758
775
  `<span id="${startId}" data-ww-revise="s"></span>` +
759
776
  responseData.html +
@@ -769,7 +786,10 @@ export default {
769
786
  if (startEl && endEl) {
770
787
  const selectRange = editor.dom.createRng()
771
788
  // Select everything between markers
772
- if (selectRange.setStartAfter && selectRange.setEndBefore) {
789
+ if (
790
+ selectRange.setStartAfter &&
791
+ selectRange.setEndBefore
792
+ ) {
773
793
  selectRange.setStartAfter(startEl)
774
794
  selectRange.setEndBefore(endEl)
775
795
  } else {
@@ -787,15 +807,19 @@ export default {
787
807
  }
788
808
  const startIndex = childIndex(startEl) + 1
789
809
  const endIndex = childIndex(endEl)
790
- selectRange.setStart(startParent, startIndex)
810
+ selectRange.setStart(
811
+ startParent,
812
+ startIndex
813
+ )
791
814
  selectRange.setEnd(endParent, endIndex)
792
815
  }
793
816
  editor.selection.setRng(selectRange)
794
817
 
795
818
  // Remove markers after selection is set
796
- if (startEl.parentNode) startEl.parentNode.removeChild(startEl)
797
- if (endEl.parentNode) endEl.parentNode.removeChild(endEl)
798
-
819
+ if (startEl.parentNode)
820
+ startEl.parentNode.removeChild(startEl)
821
+ if (endEl.parentNode)
822
+ endEl.parentNode.removeChild(endEl)
799
823
  }
800
824
  } catch (_e) {
801
825
  // Ignore selection restoration errors
@@ -3,7 +3,7 @@
3
3
  <v-text-field
4
4
  id="glossary-form-term"
5
5
  v-model="selectedTerm.term"
6
- :counter="50"
6
+ :counter="75"
7
7
  :label="$t('windward.core.pages.glossary.term')"
8
8
  required
9
9
  :rules="validation.termRules"
@@ -80,8 +80,8 @@ export default {
80
80
  termRules: [
81
81
  (v) => !!v || this.$t('shared.forms.errors.required'),
82
82
  (v) =>
83
- (v && v.length <= 50) ||
84
- this.$t('shared.forms.errors.text_lt', [50]),
83
+ (v && v.length <= 75) ||
84
+ this.$t('shared.forms.errors.text_lt', [75]),
85
85
  (v) =>
86
86
  (v && this.termIsUnique(v)) ||
87
87
  this.$t('shared.forms.errors.field_unique'),
@@ -2,10 +2,16 @@ export default {
2
2
  title: 'File Submission',
3
3
  user_uploads: 'User Uploads',
4
4
  dialog_view: 'View Uploads',
5
- instructions_none: 'None',
5
+ instructions:
6
+ 'Upload your files below. Then, review your current uploads, and select Submit when you are ready.',
6
7
  must_save: 'You must save this block before users can make uploads.',
7
8
  uploaded: 'Uploaded',
8
9
  name: 'Name',
9
10
  size: 'Size',
10
11
  download: 'Download',
12
+ review_current_uploads: 'Review Current Uploads',
13
+ submission_history: 'Submission History',
14
+ submission: 'Submission',
15
+ filename: 'Filename',
16
+ file_size: 'File Size',
11
17
  }
@@ -7,4 +7,18 @@ export default {
7
7
  click_to_expand: 'Click to expand editor',
8
8
  click_to_hide_editor: 'Click to hide editor',
9
9
  click_to_hide_glossary: 'Click to hide glossary',
10
+ case_study_help:
11
+ 'Select one or more pages to generate a fictional case study scenario based on their content.',
12
+ case_study_selected_pages: 'Source pages',
13
+ generate_case_study: 'Generate Case Study',
14
+ case_study_select_pages_error: 'Select at least one page to generate a case study.',
15
+ case_study_replace_confirm:
16
+ 'This will replace the current text in this block. Continue?',
17
+ case_study_generated: 'Case study generated.',
18
+ case_study_insufficient_content:
19
+ 'Not enough content on the selected pages. Add more content or select additional pages.',
20
+ case_study_character_limit:
21
+ 'The selected pages contain too much content. Select fewer pages and try again.',
22
+ case_study_error:
23
+ 'Unable to generate case study. Try selecting fewer pages or edit manually.',
10
24
  }
@@ -1,5 +1,5 @@
1
1
  export default {
2
- accept_multiple: 'Multiple Files',
2
+ accept_multiple: 'Allow submission of multiple files.',
3
3
  accept_types: 'Allowed File Types',
4
4
  types: {
5
5
  all: 'All File Types',
@@ -2,11 +2,17 @@ export default {
2
2
  title: 'Envío de archivos',
3
3
  user_uploads: 'Cargas de usuarios',
4
4
  dialog_view: 'Ver cargas',
5
- instructions_none: 'Ninguno',
5
+ instructions:
6
+ 'Sube tus archivos a continuación. Luego, revisa tus archivos actuales y selecciona "Enviar" cuando estés listo.',
6
7
  must_save:
7
8
  'Debes guardar este bloque antes de que los usuarios puedan realizar cargas.',
8
9
  uploaded: 'Subido',
9
10
  name: 'Nombre',
10
11
  size: 'Tamaño',
11
12
  download: 'Descargar',
13
+ review_current_uploads: 'Revisar las cargas actuales',
14
+ submission_history: 'Historial de envíos',
15
+ submission: 'Envío',
16
+ filename: 'Nombre del archivo',
17
+ file_size: 'Tamaño de archivo',
12
18
  }
@@ -7,4 +7,19 @@ export default {
7
7
  click_to_expand: 'Haga Click ampliar el editor',
8
8
  click_to_hide_editor: 'Haga click para esconder el editor',
9
9
  click_to_hide_glossary: 'Haga click para esconder el glosario',
10
+ case_study_help:
11
+ 'Seleccione una o más páginas para generar un caso práctico ficticio basado en su contenido.',
12
+ case_study_selected_pages: 'Páginas de origen',
13
+ generate_case_study: 'Generar caso práctico',
14
+ case_study_select_pages_error:
15
+ 'Seleccione al menos una página para generar un caso práctico.',
16
+ case_study_replace_confirm:
17
+ 'Esto reemplazará el texto actual de este bloque. ¿Continuar?',
18
+ case_study_generated: 'Caso práctico generado.',
19
+ case_study_insufficient_content:
20
+ 'No hay suficiente contenido en las páginas seleccionadas. Agregue más contenido o seleccione páginas adicionales.',
21
+ case_study_character_limit:
22
+ 'Las páginas seleccionadas contienen demasiado contenido. Seleccione menos páginas e inténtelo de nuevo.',
23
+ case_study_error:
24
+ 'No se pudo generar el caso práctico. Intente seleccionar menos páginas o edite manualmente.',
10
25
  }
@@ -1,5 +1,5 @@
1
1
  export default {
2
- accept_multiple: 'Archivos Múltiples',
2
+ accept_multiple: 'Permitir el envío de múltiples archivos.',
3
3
  accept_types: 'Tipos de archivos permitidos ',
4
4
  types: {
5
5
  all: 'Todos los tipos de archivos',
@@ -2,11 +2,17 @@ export default {
2
2
  title: 'Filinlämning',
3
3
  user_uploads: 'Användaruppladdningar',
4
4
  dialog_view: 'Visa uppladdningar',
5
- instructions_none: 'Ingen',
5
+ instructions:
6
+ 'Ladda upp dina filer nedan. Granska sedan dina aktuella uppladdningar och välj Skicka när du är redo.',
6
7
  must_save:
7
8
  'Du måste spara detta block innan användare kan göra uppladdningar.',
8
9
  uploaded: 'Uppladdat',
9
10
  name: 'Namn',
10
11
  size: 'Storlek',
11
12
  download: 'Ladda ner',
13
+ review_current_uploads: 'Granska aktuella uppladdningar',
14
+ submission_history: 'Inlämningshistorik',
15
+ submission: 'Underkastelse',
16
+ filename: 'Filnamn',
17
+ file_size: 'Fil-storlek',
12
18
  }
@@ -7,4 +7,19 @@ export default {
7
7
  click_to_expand: 'klicka för att förbruka',
8
8
  click_to_hide_editor: 'klicka för att dölja editorn',
9
9
  click_to_hide_glossary: 'klicka för att dölja ordlistan',
10
+ case_study_help:
11
+ 'Välj en eller flera sidor för att generera ett fiktivt fallstudiescenario baserat på deras innehåll.',
12
+ case_study_selected_pages: 'Källsidor',
13
+ generate_case_study: 'Generera fallstudie',
14
+ case_study_select_pages_error:
15
+ 'Välj minst en sida för att generera en fallstudie.',
16
+ case_study_replace_confirm:
17
+ 'Detta kommer att ersätta den nuvarande texten i detta block. Fortsätt?',
18
+ case_study_generated: 'Fallstudie genererad.',
19
+ case_study_insufficient_content:
20
+ 'Inte tillräckligt med innehåll på de valda sidorna. Lägg till mer innehåll eller välj fler sidor.',
21
+ case_study_character_limit:
22
+ 'De valda sidorna innehåller för mycket innehåll. Välj färre sidor och försök igen.',
23
+ case_study_error:
24
+ 'Kunde inte generera fallstudie. Försök välja färre sidor eller redigera manuellt.',
10
25
  }
@@ -1,5 +1,5 @@
1
1
  export default {
2
- accept_multiple: 'Flera filer',
2
+ accept_multiple: 'Tillåt inlämning av flera filer.',
3
3
  accept_types: 'Tillåtna filtyper ',
4
4
  types: {
5
5
  all: 'Alla filtyper',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windward/core",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Windward UI Core Plugins",
5
5
  "main": "plugin.js",
6
6
  "scripts": {