@windward/integrations 0.0.1

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 (54) hide show
  1. package/.editorconfig +13 -0
  2. package/.eslintrc.js +15 -0
  3. package/.prettierrc +4 -0
  4. package/README.md +19 -0
  5. package/babel.config.js +1 -0
  6. package/components/Integration/Driver/ManageAtutor.vue +143 -0
  7. package/components/Integration/Driver/ManageBase.vue +145 -0
  8. package/components/Integration/JobTable.vue +308 -0
  9. package/components/Integration/TestConnection.vue +45 -0
  10. package/config/integration.config.js +13 -0
  11. package/helpers/Driver/Atutor.ts +12 -0
  12. package/helpers/Driver/BaseDriver.ts +25 -0
  13. package/helpers/Driver/DriverInterface.ts +7 -0
  14. package/helpers/IntegrationHelper.ts +150 -0
  15. package/i18n/en-US/components/index.ts +7 -0
  16. package/i18n/en-US/components/integration/driver.ts +18 -0
  17. package/i18n/en-US/components/integration/index.ts +7 -0
  18. package/i18n/en-US/components/integration/job.ts +22 -0
  19. package/i18n/en-US/components/navigation/index.ts +5 -0
  20. package/i18n/en-US/components/navigation/integrations.ts +8 -0
  21. package/i18n/en-US/index.ts +16 -0
  22. package/i18n/en-US/modules/index.ts +5 -0
  23. package/i18n/en-US/pages/importContent.ts +3 -0
  24. package/i18n/en-US/pages/importCourse.ts +13 -0
  25. package/i18n/en-US/pages/index.ts +9 -0
  26. package/i18n/en-US/pages/vendor.ts +11 -0
  27. package/i18n/en-US/shared/error.ts +8 -0
  28. package/i18n/en-US/shared/index.ts +11 -0
  29. package/i18n/en-US/shared/menu.ts +3 -0
  30. package/i18n/en-US/shared/permission.ts +26 -0
  31. package/i18n/en-US/shared/settings.ts +1 -0
  32. package/jest.config.js +17 -0
  33. package/models/CourseSectionIntegration.ts +12 -0
  34. package/models/IntegrationJob.ts +12 -0
  35. package/models/Organization.ts +14 -0
  36. package/models/OrganizationIntegration.ts +17 -0
  37. package/models/RemoteContent.ts +12 -0
  38. package/models/RemoteCourse.ts +17 -0
  39. package/models/RemoteOrganization.ts +17 -0
  40. package/models/Vendor.ts +12 -0
  41. package/package.json +44 -0
  42. package/pages/admin/importCourse.vue +390 -0
  43. package/pages/admin/vendors.vue +241 -0
  44. package/pages/course/importContent.vue +25 -0
  45. package/plugin.js +110 -0
  46. package/test/Helpers/IntegrationHelper.spec.js +92 -0
  47. package/test/Pages/Admin/ImportCourse.spec.js +19 -0
  48. package/test/Pages/Admin/vendors.spec.js +19 -0
  49. package/test/Pages/Course/importContent.spec.js +19 -0
  50. package/test/__mocks__/lodashMock.js +31 -0
  51. package/test/__mocks__/modelMock.js +101 -0
  52. package/test/__mocks__/vuexMock.js +31 -0
  53. package/test/mocks.js +18 -0
  54. package/tsconfig.json +21 -0
package/.editorconfig ADDED
@@ -0,0 +1,13 @@
1
+ # editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ indent_style = space
6
+ indent_size = 4
7
+ end_of_line = lf
8
+ charset = utf-8
9
+ trim_trailing_whitespace = true
10
+ insert_final_newline = true
11
+
12
+ [*.md]
13
+ trim_trailing_whitespace = false
package/.eslintrc.js ADDED
@@ -0,0 +1,15 @@
1
+ module.exports = {
2
+ root: true,
3
+ env: {
4
+ browser: true,
5
+ node: true,
6
+ },
7
+ extends: [
8
+ '@nuxtjs/eslint-config-typescript',
9
+ 'plugin:nuxt/recommended',
10
+ 'plugin:prettier/recommended',
11
+ ],
12
+ plugins: [],
13
+ // add your custom rules here
14
+ rules: {},
15
+ }
package/.prettierrc ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true
4
+ }
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # Windward UI Integrations
2
+
3
+ ## Getting Started Locally with npm
4
+
5
+ **Software Requirements**:
6
+
7
+ - Node v16+, npm v8+
8
+
9
+ ## New .env variables
10
+
11
+ ```
12
+ BROADCAST_DRIVER=pusher
13
+ BROADCAST_HOST=
14
+ BROADCAST_PORT=
15
+ # windward-development pusher key found on https://dashboard.pusher.com/
16
+ BROADCAST_KEY=798e489618e5e8bce5a1
17
+ BROADCAST_CLUSTER=mt1
18
+ BROADCAST_FORCE_TLS=true
19
+ ```
@@ -0,0 +1 @@
1
+ module.exports = { presets: ["@babel/preset-env"] };
@@ -0,0 +1,143 @@
1
+ <template>
2
+ <div>
3
+ <div v-if="!render" class="integration-loading">
4
+ <v-progress-circular size="128" indeterminate />
5
+ </div>
6
+ <div v-if="render">
7
+ <v-row justify="center" align="center" class="mt-5">
8
+ <v-col cols="12">
9
+ <v-switch
10
+ v-model="integration.enabled"
11
+ :label="
12
+ $t(
13
+ 'windward.integrations.components.integration.driver.enabled'
14
+ )
15
+ "
16
+ />
17
+
18
+ <v-text-field
19
+ v-model="integration.metadata.config.url"
20
+ :label="
21
+ $t(
22
+ 'windward.integrations.components.integration.driver.atutor.url'
23
+ )
24
+ "
25
+ :hint="
26
+ $t(
27
+ 'windward.integrations.components.integration.driver.atutor.url_hint'
28
+ )
29
+ "
30
+ ></v-text-field>
31
+ <v-text-field
32
+ v-model="integration.metadata.config.username"
33
+ :label="
34
+ $t(
35
+ 'windward.integrations.components.integration.driver.atutor.username'
36
+ )
37
+ "
38
+ ></v-text-field>
39
+ <v-text-field
40
+ v-model="integration.metadata.config.password"
41
+ :label="
42
+ $t(
43
+ 'windward.integrations.components.integration.driver.atutor.password'
44
+ )
45
+ "
46
+ type="password"
47
+ ></v-text-field>
48
+
49
+ <v-text-field
50
+ v-model="integration.metadata.config.aws_secure_url"
51
+ :label="
52
+ $t(
53
+ 'windward.integrations.components.integration.driver.atutor.aws_secure_url'
54
+ )
55
+ "
56
+ :hint="
57
+ $t(
58
+ 'windward.integrations.components.integration.driver.atutor.aws_secure_url_hint'
59
+ )
60
+ "
61
+ ></v-text-field>
62
+
63
+ <v-switch
64
+ v-model="integration.metadata.config.ssl"
65
+ :label="
66
+ $t(
67
+ 'windward.integrations.components.integration.driver.ssl_enabled'
68
+ )
69
+ "
70
+ />
71
+
72
+ <TestConnection
73
+ :disabled="
74
+ !integration.metadata.config.url ||
75
+ !integration.metadata.config.username ||
76
+ !integration.metadata.config.password
77
+ "
78
+ :loading="testConnectionLoading"
79
+ :errors="errorMessage"
80
+ @click="onTestConnection"
81
+ ></TestConnection>
82
+ </v-col>
83
+ </v-row>
84
+ </div>
85
+ </div>
86
+ </template>
87
+
88
+ <script>
89
+ import TestConnection from '../TestConnection.vue'
90
+ import ManageBaseVue from './ManageBase.vue'
91
+
92
+ export default {
93
+ name: 'ManageAtutorDriver',
94
+ components: { TestConnection },
95
+ extends: ManageBaseVue,
96
+ data() {
97
+ return {
98
+ // formValid: true|false If this form is "complete" and passed validation on THIS component. Defined and watched in ManageBase.vue
99
+ // render: true|false If we should show the form aka when validation has passed. Defined and managed in ManageBase.vue
100
+ // integration: { metadata: {...} } The integration object to write to. Defined and loaded in ManageBase.vue
101
+ errorMessage: '',
102
+ testConnectionLoading: false,
103
+ }
104
+ },
105
+ methods: {
106
+ /**
107
+ * Lifecycle event called from ManageBase.vue when async fetch() completes.
108
+ * Once called this.integration will be available containing the integration model (or a new one)
109
+ */
110
+ onIntegrationLoaded() {
111
+ // Set SSL to enabled by default
112
+ if (_.get(this.integration.metadata.config, 'ssl', null) === null) {
113
+ this.integration.metadata.config.ssl = true
114
+ }
115
+ },
116
+ async onTestConnection() {
117
+ this.testConnectionLoading = true
118
+ const response = await this.testConnection(
119
+ this.integration.metadata
120
+ )
121
+
122
+ if (response.result) {
123
+ this.errorMessage = ''
124
+ this.$dialog.success(
125
+ this.$t(
126
+ 'windward.integrations.shared.error.connect_success'
127
+ )
128
+ )
129
+ } else {
130
+ this.errorMessage = response.message
131
+ this.$dialog.error(
132
+ this.$t('windward.integrations.shared.error.connect_fail')
133
+ )
134
+ }
135
+
136
+ // We will indirectly validate the form via connection tests
137
+ // That way we can 100% confirm that the integration is valid
138
+ this.formValid = response.result
139
+ this.testConnectionLoading = false
140
+ },
141
+ },
142
+ }
143
+ </script>
@@ -0,0 +1,145 @@
1
+ <script>
2
+ import { mapGetters } from 'vuex'
3
+ import Organization from '../../../models/Organization'
4
+ import OrganizationIntegration from '../../../models/OrganizationIntegration'
5
+ import Vendor from '../../../models/Vendor'
6
+ import FormVue from '~/components/Form'
7
+
8
+ export default {
9
+ name: 'ManageBase',
10
+ components: {},
11
+ extends: FormVue,
12
+ props: {
13
+ vendor: { type: Object, required: true, default: null },
14
+ },
15
+ emits: ['update:integration'],
16
+ meta: {
17
+ privilege: {
18
+ '': {
19
+ writable: true,
20
+ },
21
+ },
22
+ },
23
+ data() {
24
+ return {
25
+ render: false,
26
+ integration: { metadata: { config: {} } },
27
+ }
28
+ },
29
+ async fetch() {
30
+ if (
31
+ !this.$PermissionService.userHasAccessTo(
32
+ 'plugin.integration.organization',
33
+ 'writable'
34
+ )
35
+ ) {
36
+ // Display an angry error that they can't view this driver
37
+ this.$dialog.error(this.$t('shared.error.description_401'), {
38
+ duration: null,
39
+ action: {
40
+ text: this.$t('shared.forms.close'),
41
+ onClick: (e, toastObject) => {
42
+ toastObject.goAway(0)
43
+ },
44
+ },
45
+ })
46
+ console.error('You do not have access to this integration!')
47
+
48
+ // Return so we don't even attempt loading
49
+ return false
50
+ }
51
+
52
+ // Load the existing org settings
53
+ const integration = await new Organization({
54
+ id: this.organization.id,
55
+ })
56
+ .integrations()
57
+ .where('integration_vendor_id', this.vendor.id)
58
+ .append(['metadata'])
59
+ .first()
60
+
61
+ if (_.isEmpty(integration)) {
62
+ // Configure a new organization integration, otherwise load existing
63
+ this.integration = new OrganizationIntegration({
64
+ integration_vendor_id: this.vendor.id,
65
+ organization_id: this.organization.id,
66
+ enabled: false,
67
+ metadata: { config: {} },
68
+ })
69
+ } else {
70
+ // Make sure the `metadata.config` prop exists
71
+ this.integration = integration
72
+ }
73
+
74
+ // Make sure metadata.config is an object
75
+ if (
76
+ _.isEmpty(_.get(this.integration.metadata, 'config')) ||
77
+ _.isArray(_.get(this.integration.metadata, 'config'))
78
+ ) {
79
+ this.integration.metadata.config = {}
80
+ }
81
+
82
+ this.onIntegrationLoaded()
83
+ this.render = true
84
+ },
85
+ computed: {
86
+ ...mapGetters({
87
+ organization: 'organization/get',
88
+ }),
89
+ },
90
+ methods: {
91
+ async testConnection(config) {
92
+ const response = await Vendor.config({
93
+ method: 'POST',
94
+ data: config,
95
+ })
96
+ .custom(
97
+ 'integration-vendors/' + this.vendor.id + '/test-connection'
98
+ )
99
+ .first()
100
+
101
+ return response
102
+ },
103
+ async save() {
104
+ let integration = new OrganizationIntegration(this.integration).for(
105
+ new Organization({ id: this.organization.id })
106
+ )
107
+
108
+ try {
109
+ integration = await integration.save()
110
+ this.integration = integration
111
+
112
+ // Clone and delete the metadata since we don't need to share that around
113
+ // Also include the vendor going back for easier mapping
114
+ const integrationEvent = _.cloneDeep(integration)
115
+ delete integrationEvent.metadata
116
+
117
+ this.$dialog.success(this.$t('shared.forms.saved'))
118
+ this.$emit('update:integration', integrationEvent)
119
+ } catch (e) {
120
+ this.$dialog.error(
121
+ this.$t('windward.integrations.shared.error.save_failed')
122
+ )
123
+ }
124
+ },
125
+ async onSave() {
126
+ if (this.formValid) {
127
+ await this.save()
128
+ }
129
+ },
130
+
131
+ onIntegrationLoaded() {
132
+ console.warn(
133
+ 'Integration/Driver/ManageBase.vue onIntegrationLoaded called. Not extended!'
134
+ )
135
+ },
136
+ },
137
+ }
138
+ </script>
139
+
140
+ <style scoped>
141
+ .integration-loading {
142
+ text-align: center;
143
+ height: 150px;
144
+ }
145
+ </style>
@@ -0,0 +1,308 @@
1
+ <template>
2
+ <div>
3
+ <v-simple-table>
4
+ <template #default>
5
+ <thead>
6
+ <tr>
7
+ <th class="text-left">
8
+ {{
9
+ $t(
10
+ 'windward.integrations.components.integration.job.job_id'
11
+ )
12
+ }}
13
+ </th>
14
+ <th class="text-left">
15
+ {{
16
+ $t(
17
+ 'windward.integrations.components.integration.job.vendor_name'
18
+ )
19
+ }}
20
+ </th>
21
+ <th class="text-left">
22
+ {{
23
+ $t(
24
+ 'windward.integrations.components.integration.job.status'
25
+ )
26
+ }}
27
+ </th>
28
+ <th class="text-left col-progress">
29
+ {{
30
+ $t(
31
+ 'windward.integrations.components.integration.job.progress'
32
+ )
33
+ }}
34
+ </th>
35
+ <th class="text-left col-details">
36
+ {{
37
+ $t(
38
+ 'windward.integrations.components.integration.job.details'
39
+ )
40
+ }}
41
+ </th>
42
+ <th class="text-left">
43
+ {{
44
+ $t(
45
+ 'windward.integrations.components.integration.job.created'
46
+ )
47
+ }}
48
+ </th>
49
+ </tr>
50
+ </thead>
51
+ <tbody>
52
+ <tr v-if="jobs.length === 0">
53
+ <td colspan="100%">
54
+ {{
55
+ $t(
56
+ 'windward.integrations.components.integration.job.no_recent_jobs'
57
+ )
58
+ }}
59
+ </td>
60
+ </tr>
61
+ <tr v-for="item in jobs" :key="item.id">
62
+ <td>{{ shortUuid(item.id) }}</td>
63
+ <td>
64
+ {{
65
+ vendorFromOrgIntegration(
66
+ item.organization_integration_id
67
+ ).name
68
+ }}
69
+ </td>
70
+ <td>
71
+ {{
72
+ $t(
73
+ 'windward.integrations.components.integration.job.job_status.' +
74
+ item.status
75
+ )
76
+ }}
77
+ </td>
78
+ <td class="col-progress">
79
+ <v-row
80
+ v-if="
81
+ item.status !== 'failed' &&
82
+ item.progress < 100
83
+ "
84
+ >
85
+ <v-col cols="2">
86
+ {{ Math.ceil(item.progress) }}%
87
+ </v-col>
88
+ <v-col cols="10">
89
+ <v-progress-linear
90
+ v-model="item.progress"
91
+ buffer-value="0"
92
+ height="15"
93
+ color="primary"
94
+ stream
95
+ >
96
+ </v-progress-linear>
97
+ </v-col>
98
+ </v-row>
99
+ <span v-else-if="item.status !== 'failed'">
100
+ {{
101
+ $t(
102
+ 'windward.integrations.components.integration.job.job_completed'
103
+ )
104
+ }}
105
+ </span>
106
+ <div v-else>
107
+ <v-icon color="error"> mdi-close </v-icon>
108
+ <span class="sr-only">
109
+ {{
110
+ $t(
111
+ 'windward.integrations.components.integration.job.job_status.failed'
112
+ )
113
+ }}
114
+ </span>
115
+ {{ Math.ceil(item.progress) }}%
116
+ </div>
117
+ </td>
118
+ <td class="col-details">
119
+ {{ jobDetails(item) }}
120
+ </td>
121
+ <td>{{ $d(new Date(item.created_at), 'long') }}</td>
122
+ </tr>
123
+ </tbody>
124
+ </template>
125
+ </v-simple-table>
126
+ </div>
127
+ </template>
128
+
129
+ <script>
130
+ import Confetti from 'canvas-confetti'
131
+ import _ from 'lodash'
132
+ import { mapGetters } from 'vuex'
133
+ import Vendor from '../../models/Vendor'
134
+ import Organization from '../../models/Organization'
135
+
136
+ export default {
137
+ name: 'IntegrationJobs',
138
+ props: {
139
+ channel: { type: String, required: true },
140
+ event: { type: String, required: true },
141
+ },
142
+ data() {
143
+ return {
144
+ socket: null,
145
+ vendors: [],
146
+ orgIntegrations: [],
147
+ jobs: [],
148
+ }
149
+ },
150
+ async fetch() {
151
+ await this.loadJobs()
152
+ },
153
+ computed: {
154
+ ...mapGetters({
155
+ organization: 'organization/get',
156
+ }),
157
+ // Filters are deprecated. Use computed methods instead
158
+ shortUuid() {
159
+ return (v) => {
160
+ if (typeof v === 'undefined' || v === '') {
161
+ return '-'
162
+ }
163
+ return v.substring(v.length - 6).toUpperCase()
164
+ }
165
+ },
166
+ vendorFromOrgIntegration() {
167
+ return (id) => {
168
+ const orgIntegration = this.orgIntegrations.find(
169
+ (o) => o.id === id
170
+ )
171
+ let vendor = { name: '???' }
172
+ if (orgIntegration) {
173
+ const foundVendor = this.vendors.find(
174
+ (v) => v.id === orgIntegration.integration_vendor_id
175
+ )
176
+ if (foundVendor) {
177
+ vendor = foundVendor
178
+ }
179
+ }
180
+ // this.orgIntegrations.map(({ foo }) => foo)
181
+ return vendor
182
+ }
183
+ },
184
+ jobDetails() {
185
+ return (job) => {
186
+ if (
187
+ _.get(job.metadata, 'import.remote_course.id') &&
188
+ _.get(job.metadata, 'import.remote_course.name')
189
+ ) {
190
+ return this.$t(
191
+ 'windward.integrations.components.integration.job.job_details.import_course',
192
+ [
193
+ _.get(job.metadata, 'import.remote_course.id'),
194
+ _.get(job.metadata, 'import.remote_course.name'),
195
+ ]
196
+ )
197
+ }
198
+
199
+ return this.$t(
200
+ 'windward.integrations.components.integration.job.job_details.none'
201
+ )
202
+ }
203
+ },
204
+ },
205
+ beforeUnmount() {
206
+ this.socket.leave(this.channel)
207
+ },
208
+ mounted() {
209
+ this.socket = this.$Integration.getSocket()
210
+ this.connect(this.channel, this.event)
211
+ },
212
+ methods: {
213
+ doConfetti() {
214
+ // launch a few confetti from the left edge
215
+ Confetti({
216
+ particleCount: 150,
217
+ angle: 60,
218
+ spread: 55,
219
+ origin: { x: 0 },
220
+ })
221
+ // and launch a few from the right edge
222
+ Confetti({
223
+ particleCount: 150,
224
+ angle: 120,
225
+ spread: 55,
226
+ origin: { x: 1 },
227
+ })
228
+ },
229
+ async loadJobs() {
230
+ this.vendors = await Vendor.get()
231
+
232
+ this.orgIntegrations = await new Organization({
233
+ id: this.organization.id,
234
+ })
235
+ .integrations()
236
+ .include('vendor')
237
+ .get()
238
+
239
+ this.jobs = await new Organization({
240
+ id: this.organization.id,
241
+ })
242
+ .integrationJobs()
243
+ .orderBy('-created_at')
244
+ .limit(10)
245
+ .get()
246
+ },
247
+ connect(channel, event) {
248
+ this.socket.connector.pusher.connection.bind(
249
+ 'connected',
250
+ function (socket) {
251
+ // Connected to socket id: socket.socket_id
252
+ }
253
+ )
254
+ this.socket.connector.pusher.connection.bind(
255
+ 'disconnected',
256
+ function () {
257
+ // disconnected
258
+ }
259
+ )
260
+
261
+ // `Connecting to channel '${channel}' for event: '${event}`
262
+ this.socket.private(channel).listen(event, (eventData) => {
263
+ this.onSocketJob(eventData)
264
+ })
265
+ },
266
+
267
+ onSocketJob(data) {
268
+ if (_.isEmpty(data.integrationJob)) {
269
+ console.error('onSocketJob Missing data', data)
270
+ return false
271
+ }
272
+ let found = false
273
+
274
+ for (const job of this.jobs) {
275
+ if (job.id === data.integrationJob.id) {
276
+ // If we found the job and it isn't completed
277
+ // We check our local status state because if a socket message is "late" we don't want to un-complete a job by accident
278
+ if (job.status !== 'completed') {
279
+ job.status = data.integrationJob.status
280
+ job.progress = data.integrationJob.progress
281
+ job.metadata = data.integrationJob.metadata
282
+ }
283
+
284
+ if (job.status === 'completed') {
285
+ this.doConfetti()
286
+ }
287
+
288
+ found = true
289
+ }
290
+ }
291
+ if (!found) {
292
+ const newJob = data.integrationJob
293
+ // Push to the top
294
+ this.jobs.unshift(newJob)
295
+ }
296
+ },
297
+ },
298
+ }
299
+ </script>
300
+
301
+ <style scoped>
302
+ .col-progress {
303
+ min-width: 200px;
304
+ }
305
+ .col-details {
306
+ max-width: 100px;
307
+ }
308
+ </style>