From 053bcad80a13fda274facae48e646b23ac5a6893 Mon Sep 17 00:00:00 2001 From: Julio Montoya Date: Fri, 15 May 2020 15:19:47 +0200 Subject: [PATCH] Add vue proof of concept courses/course category --- .jshintrc | 3 + assets/vue/App.vue | 78 +++++ assets/vue/VueConfig.js | 3 + assets/vue/components/ActionCell.vue | 45 +++ assets/vue/components/Breadcrumb.vue | 40 +++ assets/vue/components/ConfirmDelete.vue | 45 +++ assets/vue/components/DataFilter.vue | 40 +++ assets/vue/components/InputDate.vue | 52 ++++ assets/vue/components/Loading.vue | 18 ++ assets/vue/components/Snackbar.vue | 32 ++ assets/vue/components/Toolbar.vue | 137 +++++++++ assets/vue/components/course/Filter.vue | 76 +++++ assets/vue/components/course/Form.vue | 173 +++++++++++ assets/vue/components/course/Layout.vue | 9 + .../vue/components/coursecategory/Filter.vue | 40 +++ assets/vue/components/coursecategory/Form.vue | 113 ++++++++ .../vue/components/coursecategory/Layout.vue | 9 + assets/vue/error/SubmissionError.js | 10 + assets/vue/i18n.js | 13 + assets/vue/locales/en.js | 22 ++ assets/vue/main.js | 67 +++++ assets/vue/mixins/CreateMixin.js | 41 +++ assets/vue/mixins/ListMixin.js | 88 ++++++ assets/vue/mixins/NotificationMixin.js | 37 +++ assets/vue/mixins/ShowMixin.js | 47 +++ assets/vue/mixins/UpdateMixin.js | 79 +++++ assets/vue/plugins/vuetify.js | 15 + assets/vue/quasar.js | 5 + assets/vue/router/course.js | 28 ++ assets/vue/router/coursecategory.js | 28 ++ assets/vue/router/index.js | 18 ++ assets/vue/services/api.js | 24 ++ assets/vue/services/course.js | 3 + assets/vue/services/coursecategory.js | 3 + assets/vue/store/index.js | 14 + assets/vue/store/modules/crud.js | 273 ++++++++++++++++++ assets/vue/store/modules/notifications.js | 18 ++ assets/vue/utils/dates.js | 9 + assets/vue/utils/fetch.js | 71 +++++ assets/vue/utils/hydra.js | 19 ++ assets/vue/validators/date.js | 7 + assets/vue/views/course/Create.vue | 46 +++ assets/vue/views/course/List.vue | 122 ++++++++ assets/vue/views/course/Show.vue | 119 ++++++++ assets/vue/views/course/Update.vue | 67 +++++ assets/vue/views/coursecategory/Create.vue | 45 +++ assets/vue/views/coursecategory/List.vue | 104 +++++++ assets/vue/views/coursecategory/Show.vue | 87 ++++++ assets/vue/views/coursecategory/Update.vue | 61 ++++ assets/vue/vue.config.js | 11 + package.json | 67 +++-- webpack.config.js | 66 +++-- 52 files changed, 2602 insertions(+), 45 deletions(-) create mode 100644 .jshintrc create mode 100644 assets/vue/App.vue create mode 100644 assets/vue/VueConfig.js create mode 100644 assets/vue/components/ActionCell.vue create mode 100644 assets/vue/components/Breadcrumb.vue create mode 100644 assets/vue/components/ConfirmDelete.vue create mode 100644 assets/vue/components/DataFilter.vue create mode 100644 assets/vue/components/InputDate.vue create mode 100644 assets/vue/components/Loading.vue create mode 100644 assets/vue/components/Snackbar.vue create mode 100644 assets/vue/components/Toolbar.vue create mode 100644 assets/vue/components/course/Filter.vue create mode 100644 assets/vue/components/course/Form.vue create mode 100644 assets/vue/components/course/Layout.vue create mode 100644 assets/vue/components/coursecategory/Filter.vue create mode 100644 assets/vue/components/coursecategory/Form.vue create mode 100644 assets/vue/components/coursecategory/Layout.vue create mode 100644 assets/vue/error/SubmissionError.js create mode 100644 assets/vue/i18n.js create mode 100644 assets/vue/locales/en.js create mode 100644 assets/vue/main.js create mode 100644 assets/vue/mixins/CreateMixin.js create mode 100644 assets/vue/mixins/ListMixin.js create mode 100644 assets/vue/mixins/NotificationMixin.js create mode 100644 assets/vue/mixins/ShowMixin.js create mode 100644 assets/vue/mixins/UpdateMixin.js create mode 100644 assets/vue/plugins/vuetify.js create mode 100644 assets/vue/quasar.js create mode 100644 assets/vue/router/course.js create mode 100644 assets/vue/router/coursecategory.js create mode 100644 assets/vue/router/index.js create mode 100644 assets/vue/services/api.js create mode 100644 assets/vue/services/course.js create mode 100644 assets/vue/services/coursecategory.js create mode 100644 assets/vue/store/index.js create mode 100644 assets/vue/store/modules/crud.js create mode 100644 assets/vue/store/modules/notifications.js create mode 100644 assets/vue/utils/dates.js create mode 100644 assets/vue/utils/fetch.js create mode 100644 assets/vue/utils/hydra.js create mode 100644 assets/vue/validators/date.js create mode 100644 assets/vue/views/course/Create.vue create mode 100644 assets/vue/views/course/List.vue create mode 100644 assets/vue/views/course/Show.vue create mode 100644 assets/vue/views/course/Update.vue create mode 100644 assets/vue/views/coursecategory/Create.vue create mode 100644 assets/vue/views/coursecategory/List.vue create mode 100644 assets/vue/views/coursecategory/Show.vue create mode 100644 assets/vue/views/coursecategory/Update.vue create mode 100644 assets/vue/vue.config.js diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000000..4a20e4c6b3 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,3 @@ +{ + "esnext": true +} \ No newline at end of file diff --git a/assets/vue/App.vue b/assets/vue/App.vue new file mode 100644 index 0000000000..570b1852c6 --- /dev/null +++ b/assets/vue/App.vue @@ -0,0 +1,78 @@ + + + \ No newline at end of file diff --git a/assets/vue/VueConfig.js b/assets/vue/VueConfig.js new file mode 100644 index 0000000000..d236f75f77 --- /dev/null +++ b/assets/vue/VueConfig.js @@ -0,0 +1,3 @@ + export const VueConfig = { + delimiters: ['[[', ']]'] + }; \ No newline at end of file diff --git a/assets/vue/components/ActionCell.vue b/assets/vue/components/ActionCell.vue new file mode 100644 index 0000000000..26aaba9fbc --- /dev/null +++ b/assets/vue/components/ActionCell.vue @@ -0,0 +1,45 @@ + + + diff --git a/assets/vue/components/Breadcrumb.vue b/assets/vue/components/Breadcrumb.vue new file mode 100644 index 0000000000..321feb9782 --- /dev/null +++ b/assets/vue/components/Breadcrumb.vue @@ -0,0 +1,40 @@ + + + diff --git a/assets/vue/components/ConfirmDelete.vue b/assets/vue/components/ConfirmDelete.vue new file mode 100644 index 0000000000..6d9a65767c --- /dev/null +++ b/assets/vue/components/ConfirmDelete.vue @@ -0,0 +1,45 @@ + + + diff --git a/assets/vue/components/DataFilter.vue b/assets/vue/components/DataFilter.vue new file mode 100644 index 0000000000..410bd23d41 --- /dev/null +++ b/assets/vue/components/DataFilter.vue @@ -0,0 +1,40 @@ + + + diff --git a/assets/vue/components/InputDate.vue b/assets/vue/components/InputDate.vue new file mode 100644 index 0000000000..b147d07edf --- /dev/null +++ b/assets/vue/components/InputDate.vue @@ -0,0 +1,52 @@ + + + diff --git a/assets/vue/components/Loading.vue b/assets/vue/components/Loading.vue new file mode 100644 index 0000000000..bceef71976 --- /dev/null +++ b/assets/vue/components/Loading.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/vue/components/Snackbar.vue b/assets/vue/components/Snackbar.vue new file mode 100644 index 0000000000..90af719cab --- /dev/null +++ b/assets/vue/components/Snackbar.vue @@ -0,0 +1,32 @@ + + + diff --git a/assets/vue/components/Toolbar.vue b/assets/vue/components/Toolbar.vue new file mode 100644 index 0000000000..46a8ba975a --- /dev/null +++ b/assets/vue/components/Toolbar.vue @@ -0,0 +1,137 @@ + + + diff --git a/assets/vue/components/course/Filter.vue b/assets/vue/components/course/Filter.vue new file mode 100644 index 0000000000..2993745f42 --- /dev/null +++ b/assets/vue/components/course/Filter.vue @@ -0,0 +1,76 @@ + + + diff --git a/assets/vue/components/course/Form.vue b/assets/vue/components/course/Form.vue new file mode 100644 index 0000000000..0ac743c532 --- /dev/null +++ b/assets/vue/components/course/Form.vue @@ -0,0 +1,173 @@ + + + diff --git a/assets/vue/components/course/Layout.vue b/assets/vue/components/course/Layout.vue new file mode 100644 index 0000000000..a259b21e61 --- /dev/null +++ b/assets/vue/components/course/Layout.vue @@ -0,0 +1,9 @@ + + + diff --git a/assets/vue/components/coursecategory/Filter.vue b/assets/vue/components/coursecategory/Filter.vue new file mode 100644 index 0000000000..8c4df7aa91 --- /dev/null +++ b/assets/vue/components/coursecategory/Filter.vue @@ -0,0 +1,40 @@ + + + diff --git a/assets/vue/components/coursecategory/Form.vue b/assets/vue/components/coursecategory/Form.vue new file mode 100644 index 0000000000..b2f40a1117 --- /dev/null +++ b/assets/vue/components/coursecategory/Form.vue @@ -0,0 +1,113 @@ + + + diff --git a/assets/vue/components/coursecategory/Layout.vue b/assets/vue/components/coursecategory/Layout.vue new file mode 100644 index 0000000000..d0a325c564 --- /dev/null +++ b/assets/vue/components/coursecategory/Layout.vue @@ -0,0 +1,9 @@ + + + diff --git a/assets/vue/error/SubmissionError.js b/assets/vue/error/SubmissionError.js new file mode 100644 index 0000000000..ee7fa025b6 --- /dev/null +++ b/assets/vue/error/SubmissionError.js @@ -0,0 +1,10 @@ +export default class SubmissionError extends Error { + constructor (errors) { + super('Submit Validation Failed'); + this.errors = errors; + //Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + + return this; + } +} diff --git a/assets/vue/i18n.js b/assets/vue/i18n.js new file mode 100644 index 0000000000..d1010621eb --- /dev/null +++ b/assets/vue/i18n.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import VueI18n from 'vue-i18n'; +import messages from './locales/en'; + +Vue.use(VueI18n); + +export default new VueI18n({ + locale: process.env.VUE_APP_I18N_LOCALE || 'en', + fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || 'en', + messages: { + en: messages + } +}); \ No newline at end of file diff --git a/assets/vue/locales/en.js b/assets/vue/locales/en.js new file mode 100644 index 0000000000..01bb478bf0 --- /dev/null +++ b/assets/vue/locales/en.js @@ -0,0 +1,22 @@ +export default { + 'Submit': 'Submit', + 'Reset': 'Reset', + 'Delete': 'Delete', + 'Edit': 'Edit', + 'Are you sure you want to delete this item?': 'Are you sure you want to delete this item?', + 'No results': 'No results', + 'Close': 'Close', + 'Cancel': 'Cancel', + 'Updated': 'Updated', + 'Field': 'Field', + 'Value': 'Value', + 'Filters': 'Filters', + 'Filter': 'Filter', + 'Data unavailable': 'Data unavailable', + 'Loading...': 'Loading...', + 'Deleted': 'Deleted', + 'Please, insert a value bigger than zero!': 'Please, insert a value bigger than zero!', + 'Please type something': 'Please type something', + 'Field is required': 'Field is required', + 'Records per page:': 'Records per page:', +}; diff --git a/assets/vue/main.js b/assets/vue/main.js new file mode 100644 index 0000000000..b219724cc1 --- /dev/null +++ b/assets/vue/main.js @@ -0,0 +1,67 @@ +import Vue from "vue"; +import App from "./App"; +import router from "./router"; +import store from "./store"; +import courseCategoryService from './services/coursecategory'; +import courseService from './services/course'; +import makeCrudModule from './store/modules/crud'; + +// import '@mdi/font/css/materialdesignicons.css' + +/*router.beforeEach((to, from, next) => { + // hack to allow for forward slashes in path ids + if (to.fullPath.includes('%2F')) { + next(to.fullPath.replace('%2F', '/')); + } + next(); +});*/ + +import vuetify from './plugins/vuetify' // path to vuetify export + +import ApolloClient from 'apollo-boost' +const apolloClient = new ApolloClient({ + // You should use an absolute URL here + uri: '/api/graphql/' +}) + +import VueApollo from 'vue-apollo'; +Vue.use(VueApollo); + +import Vuelidate from 'vuelidate'; +import i18n from './i18n'; +Vue.config.productionTip = false; +Vue.use(Vuelidate); + +const apolloProvider = new VueApollo({ + defaultClient: apolloClient, +}); + +//import './quasar' + +store.registerModule( + 'course', + makeCrudModule({ + service: courseService + }) +); + +store.registerModule( + 'coursecategory', + makeCrudModule({ + service: courseCategoryService + }) +); + +Vue.config.productionTip = false; + +new Vue({ + vuetify, + i18n, + components: {App}, + apolloProvider, + data: {}, + store, + router, + render: h => h(App) +}). +$mount("#app"); diff --git a/assets/vue/mixins/CreateMixin.js b/assets/vue/mixins/CreateMixin.js new file mode 100644 index 0000000000..c263c10614 --- /dev/null +++ b/assets/vue/mixins/CreateMixin.js @@ -0,0 +1,41 @@ +import NotificationMixin from './NotificationMixin'; +import { formatDateTime } from '../utils/dates'; + +export default { + mixins: [NotificationMixin], + methods: { + formatDateTime, + onCreated(item) { + this.showMessage(`${item['@id']} created`); + + this.$router.push({ + name: `${this.$options.servicePrefix}Update`, + params: { id: item['@id'] } + }); + }, + onSendForm() { + const createForm = this.$refs.createForm; + createForm.$v.$touch(); + if (!createForm.$v.$invalid) { + this.create(createForm.$v.item.$model); + } + }, + resetForm() { + this.$refs.createForm.$v.$reset(); + this.item = {}; + } + }, + watch: { + created(created) { + if (!created) { + return; + } + + this.onCreated(created); + }, + + error(message) { + message && this.showError(message); + } + } +}; diff --git a/assets/vue/mixins/ListMixin.js b/assets/vue/mixins/ListMixin.js new file mode 100644 index 0000000000..b5a2479388 --- /dev/null +++ b/assets/vue/mixins/ListMixin.js @@ -0,0 +1,88 @@ +import isEmpty from 'lodash/isEmpty'; +import { formatDateTime } from '../utils/dates'; +import NotificationMixin from './NotificationMixin'; + +export default { + mixins: [NotificationMixin], + + data() { + return { + options: { + sortBy: [], + descending: false, + page: 1, + itemsPerPage: 15 + }, + filters: {} + }; + }, + + watch: { + deletedItem(item) { + this.showMessage(`${item['@id']} deleted.`); + }, + + error(message) { + message && this.showError(message); + }, + + items() { + this.options.totalItems = this.totalItems; + } + }, + + methods: { + onUpdateOptions(props) { + const { page, itemsPerPage, sortBy, descending, totalItems } = props; + let params = { + ...this.filters + }; + if (itemsPerPage > 0) { + params = { ...params, itemsPerPage, page }; + } + + if (!isEmpty(sortBy)) { + params[`order[${sortBy}]`] = descending ? 'desc' : 'asc'; + } + + this.getPage(params).then(() => { + this.options.sortBy = sortBy; + this.options.descending = descending; + this.options.itemsPerPage = itemsPerPage; + this.options.totalItems = totalItems; + }); + }, + + onSendFilter() { + this.resetList = true; + this.onUpdateOptions(this.options); + }, + + resetFilter() { + this.filters = {}; + }, + + addHandler() { + this.$router.push({ name: `${this.$options.servicePrefix}Create` }); + }, + + showHandler(item) { + this.$router.push({ + name: `${this.$options.servicePrefix}Show`, + params: { id: item['@id'] } + }); + }, + + editHandler(item) { + this.$router.push({ + name: `${this.$options.servicePrefix}Update`, + params: { id: item['@id'] } + }); + }, + + deleteHandler(item) { + this.deleteItem(item).then(() => this.onUpdateOptions(this.options)); + }, + formatDateTime + } +}; diff --git a/assets/vue/mixins/NotificationMixin.js b/assets/vue/mixins/NotificationMixin.js new file mode 100644 index 0000000000..9ad6134771 --- /dev/null +++ b/assets/vue/mixins/NotificationMixin.js @@ -0,0 +1,37 @@ +import { mapFields } from 'vuex-map-fields'; + +export default { + computed: { + ...mapFields('notifications', ['color', 'show', 'subText', 'text', 'timeout']) + }, + + methods: { + cleanState() { + setTimeout(() => { + this.show = false; + }, this.timeout); + }, + + showError(error) { + this.showMessage(error, 'danger'); + }, + + showMessage(message, color = 'success') { + this.show = true; + this.color = color; + + if (typeof message === 'string') { + this.text = message; + this.cleanState(); + + return; + } + + this.text = message.message; + + if (message.response) this.subText = message.response.data.message; + + this.cleanState(); + } + } +}; diff --git a/assets/vue/mixins/ShowMixin.js b/assets/vue/mixins/ShowMixin.js new file mode 100644 index 0000000000..a6080712cc --- /dev/null +++ b/assets/vue/mixins/ShowMixin.js @@ -0,0 +1,47 @@ +import NotificationMixin from './NotificationMixin'; +import { formatDateTime } from '../utils/dates'; + +export default { + mixins: [NotificationMixin], + created() { + this.retrieve(decodeURIComponent(this.$route.params.id)); + }, + computed: { + item() { + return this.find(decodeURIComponent(this.$route.params.id)); + } + }, + methods: { + list() { + this.$router + .push({ name: `${this.$options.servicePrefix}List` }) + .catch(() => {}); + }, + del() { + this.deleteItem(this.item).then(() => { + this.showMessage(`${this.item['@id']} deleted.`); + this.$router + .push({ name: `${this.$options.servicePrefix}List` }) + .catch(() => {}); + }); + }, + formatDateTime, + editHandler() { + this.$router.push({ + name: `${this.$options.servicePrefix}Update`, + params: { id: this.item['@id'] } + }); + } + }, + watch: { + error(message) { + message && this.showError(message); + }, + deleteError(message) { + message && this.showError(message); + } + }, + beforeDestroy() { + this.reset(); + } +}; diff --git a/assets/vue/mixins/UpdateMixin.js b/assets/vue/mixins/UpdateMixin.js new file mode 100644 index 0000000000..0ab0493cad --- /dev/null +++ b/assets/vue/mixins/UpdateMixin.js @@ -0,0 +1,79 @@ +import NotificationMixin from './NotificationMixin'; +import { formatDateTime } from '../utils/dates'; + +export default { + mixins: [NotificationMixin], + data() { + return { + item: {} + }; + }, + created() { + this.retrieve(decodeURIComponent(this.$route.params.id)); + }, + beforeDestroy() { + this.reset(); + }, + computed: { + retrieved() { + return this.find(decodeURIComponent(this.$route.params.id)); + } + }, + methods: { + del() { + this.deleteItem(this.retrieved).then(() => { + this.showMessage(`${this.item['@id']} deleted.`); + this.$router + .push({ name: `${this.$options.servicePrefix}List` }) + .catch(() => {}); + }); + }, + formatDateTime, + reset() { + this.$refs.updateForm.$v.$reset(); + this.updateReset(); + this.delReset(); + this.createReset(); + }, + + onSendForm() { + const updateForm = this.$refs.updateForm; + updateForm.$v.$touch(); + + if (!updateForm.$v.$invalid) { + this.update(updateForm.$v.item.$model); + } + }, + + resetForm() { + this.$refs.updateForm.$v.$reset(); + this.item = { ...this.retrieved }; + } + }, + watch: { + deleted(deleted) { + if (!deleted) { + return; + } + this.$router + .push({ name: `${this.$options.servicePrefix}List` }) + .catch(() => {}); + }, + + error(message) { + message && this.showError(message); + }, + + deleteError(message) { + message && this.showError(message); + }, + + updated(val) { + this.showMessage(`${val['@id']} updated.`); + }, + + retrieved(val) { + this.item = { ...val }; + } + } +}; diff --git a/assets/vue/plugins/vuetify.js b/assets/vue/plugins/vuetify.js new file mode 100644 index 0000000000..e7b729a19e --- /dev/null +++ b/assets/vue/plugins/vuetify.js @@ -0,0 +1,15 @@ +// src/plugins/vuetify.js + +import Vue from 'vue' +import Vuetify from 'vuetify' +import 'vuetify/dist/vuetify.min.css' + +Vue.use(Vuetify) + +const opts = { + icons: { + iconfont: 'mdi' + } +}; + +export default new Vuetify(opts) \ No newline at end of file diff --git a/assets/vue/quasar.js b/assets/vue/quasar.js new file mode 100644 index 0000000000..94423fc7e5 --- /dev/null +++ b/assets/vue/quasar.js @@ -0,0 +1,5 @@ +import Vue from 'vue' + +// import './styles/quasar.sass' +import '@quasar/extras/material-icons/material-icons.css' +import Quasar from 'quasar/dist/quasar.umd.js' diff --git a/assets/vue/router/course.js b/assets/vue/router/course.js new file mode 100644 index 0000000000..05c7ccab9e --- /dev/null +++ b/assets/vue/router/course.js @@ -0,0 +1,28 @@ +export default { + path: '/courses', + name: 'courses', + component: () => import('../components/course/Layout'), + redirect: { name: 'CourseList' }, + children: [ + { + name: 'CourseList', + path: '', + component: () => import('../views/course/List') + }, + { + name: 'CourseCreate', + path: 'new', + component: () => import('../views/course/Create') + }, + { + name: 'CourseUpdate', + path: ':id/edit', + component: () => import('../views/course/Update') + }, + { + name: 'CourseShow', + path: ':id', + component: () => import('../views/course/Show') + } + ] +}; diff --git a/assets/vue/router/coursecategory.js b/assets/vue/router/coursecategory.js new file mode 100644 index 0000000000..c0b6785121 --- /dev/null +++ b/assets/vue/router/coursecategory.js @@ -0,0 +1,28 @@ +export default { + path: '/course_categories', + name: 'course_categories', + component: () => import('../components/coursecategory/Layout'), + redirect: { name: 'CourseCategoryList' }, + children: [ + { + name: 'CourseCategoryList', + path: '', + component: () => import('../views/coursecategory/List') + }, + { + name: 'CourseCategoryCreate', + path: 'new', + component: () => import('../views/coursecategory/Create') + }, + { + name: 'CourseCategoryUpdate', + path: ':id/edit', + component: () => import('../views/coursecategory/Update') + }, + { + name: 'CourseCategoryShow', + path: ':id', + component: () => import('../views/coursecategory/Show') + } + ] +}; diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js new file mode 100644 index 0000000000..96fe6587d7 --- /dev/null +++ b/assets/vue/router/index.js @@ -0,0 +1,18 @@ +import Vue from "vue"; +import VueRouter from "vue-router"; + +Vue.use(VueRouter); + +import courseRoutes from './course'; +import coursecategoryRoutes from './coursecategory'; +import sessionRoutes from './../../quasar/router/session'; + +export default new VueRouter({ + mode: "history", + routes: [ + courseRoutes, + ...sessionRoutes, + coursecategoryRoutes, + // { path: "*", redirect: "/home" } + ] +}); diff --git a/assets/vue/services/api.js b/assets/vue/services/api.js new file mode 100644 index 0000000000..69576af72a --- /dev/null +++ b/assets/vue/services/api.js @@ -0,0 +1,24 @@ +import fetch from '../utils/fetch'; + +export default function makeService(endpoint) { + return { + find(id) { + return fetch(`${id}`); + }, + findAll(params) { + return fetch(endpoint, params); + }, + create(payload) { + return fetch(endpoint, { method: 'POST', body: JSON.stringify(payload) }); + }, + del(item) { + return fetch(item['@id'], { method: 'DELETE' }); + }, + update(payload) { + return fetch(payload['@id'], { + method: 'PUT', + body: JSON.stringify(payload) + }); + } + }; +} diff --git a/assets/vue/services/course.js b/assets/vue/services/course.js new file mode 100644 index 0000000000..da7212cee8 --- /dev/null +++ b/assets/vue/services/course.js @@ -0,0 +1,3 @@ +import makeService from './api'; + +export default makeService('courses'); diff --git a/assets/vue/services/coursecategory.js b/assets/vue/services/coursecategory.js new file mode 100644 index 0000000000..0515acdc46 --- /dev/null +++ b/assets/vue/services/coursecategory.js @@ -0,0 +1,3 @@ +import makeService from './api'; + +export default makeService('course_categories'); diff --git a/assets/vue/store/index.js b/assets/vue/store/index.js new file mode 100644 index 0000000000..4fc086ccf8 --- /dev/null +++ b/assets/vue/store/index.js @@ -0,0 +1,14 @@ +import Vue from "vue"; +import Vuex from "vuex"; + +import notifications from './modules/notifications'; +//import session from './../../quasar/store/modules/session/'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { + notifications, + //session, + } +}); \ No newline at end of file diff --git a/assets/vue/store/modules/crud.js b/assets/vue/store/modules/crud.js new file mode 100644 index 0000000000..a293f09bae --- /dev/null +++ b/assets/vue/store/modules/crud.js @@ -0,0 +1,273 @@ +import Vue from 'vue'; +import { getField, updateField } from 'vuex-map-fields'; +import remove from 'lodash/remove'; +import SubmissionError from '../../error/SubmissionError'; + +const initialState = () => ({ + allIds: [], + byId: {}, + created: null, + deleted: null, + error: "", + isLoading: false, + resetList: false, + selectItems: null, + totalItems: 0, + updated: null, + view: null, + violations: null +}); + +const handleError = (commit, e) => { + commit(ACTIONS.TOGGLE_LOADING); + + if (e instanceof SubmissionError) { + commit(ACTIONS.SET_VIOLATIONS, e.errors); + // eslint-disable-next-line + commit(ACTIONS.SET_ERROR, e.errors._error); + + return Promise.reject(e); + } + + // eslint-disable-next-line + commit(ACTIONS.SET_ERROR, e.message); + + return Promise.reject(e); +}; + +export const ACTIONS = { + ADD: 'ADD', + RESET_CREATE: 'RESET_CREATE', + RESET_DELETE: 'RESET_DELETE', + RESET_LIST: 'RESET_LIST', + RESET_SHOW: 'RESET_SHOW', + RESET_UPDATE: 'RESET_UPDATE', + SET_CREATED: 'SET_CREATED', + SET_DELETED: 'SET_DELETED', + SET_ERROR: 'SET_ERROR', + SET_SELECT_ITEMS: 'SET_SELECT_ITEMS', + SET_TOTAL_ITEMS: 'SET_TOTAL_ITEMS', + SET_UPDATED: 'SET_UPDATED', + SET_VIEW: 'SET_VIEW', + SET_VIOLATIONS: 'SET_VIOLATIONS', + TOGGLE_LOADING: 'TOGGLE_LOADING' +}; + +export default function makeCrudModule({ + normalizeRelations = x => x, + resolveRelations = x => x, + service +} = {}) { + return { + actions: { + create: ({ commit }, values) => { + commit(ACTIONS.SET_ERROR, ''); + commit(ACTIONS.TOGGLE_LOADING); + + service + .create(values) + .then(response => response.json()) + .then(data => { + commit(ACTIONS.TOGGLE_LOADING); + commit(ACTIONS.ADD, data); + commit(ACTIONS.SET_CREATED, data); + }) + .catch(e => handleError(commit, e)); + }, + del: ({ commit }, item) => { + commit(ACTIONS.TOGGLE_LOADING); + + service + .del(item) + .then(() => { + commit(ACTIONS.TOGGLE_LOADING); + commit(ACTIONS.SET_DELETED, item); + }) + .catch(e => handleError(commit, e)); + }, + fetchAll: ({ commit, state }, params) => { + if (!service) throw new Error('No service specified!'); + + commit(ACTIONS.TOGGLE_LOADING); + + service + .findAll({ params }) + .then(response => response.json()) + .then(retrieved => { + commit(ACTIONS.TOGGLE_LOADING); + + commit( + ACTIONS.SET_TOTAL_ITEMS, + retrieved['hydra:totalItems'] + ); + commit(ACTIONS.SET_VIEW, retrieved['hydra:view']); + + if (true === state.resetList) { + commit(ACTIONS.RESET_LIST); + } + + retrieved['hydra:member'].forEach(item => { + commit(ACTIONS.ADD, normalizeRelations(item)); + }); + }) + .catch(e => handleError(commit, e)); + }, + fetchSelectItems: ( + { commit }, + { params = { properties: ['@id', 'name'] } } = {} + ) => { + commit(ACTIONS.TOGGLE_LOADING); + + if (!service) throw new Error('No service specified!'); + + service + .findAll({ params }) + .then(response => response.json()) + .then(retrieved => { + commit( + ACTIONS.SET_SELECT_ITEMS, + retrieved['hydra:member'] + ); + }) + .catch(e => handleError(commit, e)); + }, + load: ({ commit }, id) => { + if (!service) throw new Error('No service specified!'); + + commit(ACTIONS.TOGGLE_LOADING); + service + .find(id) + .then(response => response.json()) + .then(item => { + commit(ACTIONS.TOGGLE_LOADING); + commit(ACTIONS.ADD, normalizeRelations(item)); + }) + .catch(e => handleError(commit, e)); + }, + resetCreate: ({ commit }) => { + commit(ACTIONS.RESET_CREATE); + }, + resetDelete: ({ commit }) => { + commit(ACTIONS.RESET_DELETE); + }, + resetShow: ({ commit }) => { + commit(ACTIONS.RESET_SHOW); + }, + resetUpdate: ({ commit }) => { + commit(ACTIONS.RESET_UPDATE); + }, + update: ({ commit }, item) => { + commit(ACTIONS.SET_ERROR, ''); + commit(ACTIONS.TOGGLE_LOADING); + + service + .update(item) + .then(response => response.json()) + .then(data => { + commit(ACTIONS.TOGGLE_LOADING); + commit(ACTIONS.SET_UPDATED, data); + }) + .catch(e => handleError(commit, e)); + } + }, + getters: { + find: state => id => { + return resolveRelations(state.byId[id]); + }, + getField, + list: (state, getters) => { + return state.allIds.map(id => getters.find(id)); + } + }, + mutations: { + updateField, + [ACTIONS.ADD]: (state, item) => { + Vue.set(state.byId, item['@id'], item); + Vue.set(state, 'isLoading', false); + if (state.allIds.includes(item['@id'])) return; + state.allIds.push(item['@id']); + }, + [ACTIONS.RESET_CREATE]: state => { + Object.assign(state, { + isLoading: false, + error: '', + created: null, + violations: null + }); + }, + [ACTIONS.RESET_DELETE]: state => { + Object.assign(state, { + isLoading: false, + error: '', + deleted: null + }); + }, + [ACTIONS.RESET_LIST]: state => { + Object.assign(state, { + allIds: [], + byId: {}, + error: '', + isLoading: false, + resetList: false + }); + }, + [ACTIONS.RESET_SHOW]: state => { + Object.assign(state, { + error: '', + isLoading: false + }); + }, + [ACTIONS.RESET_UPDATE]: state => { + Object.assign(state, { + error: '', + isLoading: false, + updated: null, + violations: null + }); + }, + [ACTIONS.SET_CREATED]: (state, created) => { + Object.assign(state, { created }); + }, + [ACTIONS.SET_DELETED]: (state, deleted) => { + if (!state.allIds.includes(deleted['@id'])) return; + Object.assign(state, { + allIds: remove(state.allIds, item => item['@id'] === deleted['@id']), + byId: remove(state.byId, id => id === deleted['@id']), + deleted + }); + }, + [ACTIONS.SET_ERROR]: (state, error) => { + Object.assign(state, { error, isLoading: false }); + }, + [ACTIONS.SET_SELECT_ITEMS]: (state, selectItems) => { + Object.assign(state, { + error: '', + isLoading: false, + selectItems + }); + }, + [ACTIONS.SET_TOTAL_ITEMS]: (state, totalItems) => { + Object.assign(state, { totalItems }); + }, + [ACTIONS.SET_UPDATED]: (state, updated) => { + Object.assign(state, { + byId: { + [updated['@id']]: updated + }, + updated + }); + }, + [ACTIONS.SET_VIEW]: (state, view) => { + Object.assign(state, { view }); + }, + [ACTIONS.SET_VIOLATIONS]: (state, violations) => { + Object.assign(state, { violations }); + }, + [ACTIONS.TOGGLE_LOADING]: state => { + Object.assign(state, { error: '', isLoading: !state.isLoading }); + } + }, + namespaced: true, + state: initialState + }; +} diff --git a/assets/vue/store/modules/notifications.js b/assets/vue/store/modules/notifications.js new file mode 100644 index 0000000000..e73d18999d --- /dev/null +++ b/assets/vue/store/modules/notifications.js @@ -0,0 +1,18 @@ +import {getField, updateField} from 'vuex-map-fields'; + +export default { + namespaced: true, + state: { + show: false, + color: 'error', + text: 'An error occurred', + subText: '', + timeout: 6000 + }, + getters: { + getField + }, + mutations: { + updateField + } +}; diff --git a/assets/vue/utils/dates.js b/assets/vue/utils/dates.js new file mode 100644 index 0000000000..c379e8da7e --- /dev/null +++ b/assets/vue/utils/dates.js @@ -0,0 +1,9 @@ +import moment from 'moment'; + +const formatDateTime = function(date) { + if (!date) return null; + + return moment(date).format('DD/MM/YYYY'); +}; + +export { formatDateTime }; diff --git a/assets/vue/utils/fetch.js b/assets/vue/utils/fetch.js new file mode 100644 index 0000000000..09a7ea5224 --- /dev/null +++ b/assets/vue/utils/fetch.js @@ -0,0 +1,71 @@ +import isObject from 'lodash/isObject'; +import { ENTRYPOINT } from '../config/entrypoint'; +import SubmissionError from '../error/SubmissionError'; +import { normalize } from './hydra'; + +const MIME_TYPE = 'application/ld+json'; + +const makeParamArray = (key, arr) => + arr.map(val => `${key}[]=${val}`).join('&'); + +export default function(id, options = {}) { + if ('undefined' === typeof options.headers) options.headers = new Headers(); + + if (null === options.headers.get('Accept')) + options.headers.set('Accept', MIME_TYPE); + + if ( + 'undefined' !== options.body && + !(options.body instanceof FormData) && + null === options.headers.get('Content-Type') + ) + options.headers.set('Content-Type', MIME_TYPE); + + if (options.params) { + const params = normalize(options.params); + let queryString = Object.keys(params) + .map(key => + Array.isArray(params[key]) + ? makeParamArray(key, params[key]) + : `${key}=${params[key]}` + ) + .join('&'); + id = `${id}?${queryString}`; + } + + const entryPoint = ENTRYPOINT + (ENTRYPOINT.endsWith('/') ? '' : '/'); + + const payload = options.body && JSON.parse(options.body); + if (isObject(payload) && payload['@id']) + options.body = JSON.stringify(normalize(payload)); + + //console.log(id); console.log(new URL(id, entryPoint)); + + return global.fetch(new URL(id, entryPoint), options).then(response => { + if (response.ok) return response; + + return response.json().then( + json => { + const error = + json['hydra:description'] || + json['hydra:title'] || + 'An error occurred.'; + + if (!json.violations) throw Error(error); + + let errors = { _error: error }; + json.violations.forEach(violation => + errors[violation.propertyPath] + ? (errors[violation.propertyPath] += + '\n' + errors[violation.propertyPath]) + : (errors[violation.propertyPath] = violation.message) + ); + + throw new SubmissionError(errors); + }, + () => { + throw new Error(response.statusText || 'An error occurred.'); + } + ); + }); +} diff --git a/assets/vue/utils/hydra.js b/assets/vue/utils/hydra.js new file mode 100644 index 0000000000..6d23ee758a --- /dev/null +++ b/assets/vue/utils/hydra.js @@ -0,0 +1,19 @@ +import get from 'lodash/get'; +import has from 'lodash/has'; +import mapValues from 'lodash/mapValues'; + +export function normalize(data) { + if (has(data, 'hydra:member')) { + // Normalize items in collections + data['hydra:member'] = data['hydra:member'].map(item => normalize(item)); + + return data; + } + + // Flatten nested documents + return mapValues(data, value => + Array.isArray(value) + ? value.map(v => get(v, '@id', v)) + : get(value, '@id', value) + ); +} diff --git a/assets/vue/validators/date.js b/assets/vue/validators/date.js new file mode 100644 index 0000000000..d2a12c7e53 --- /dev/null +++ b/assets/vue/validators/date.js @@ -0,0 +1,7 @@ +import moment from 'moment'; + +const date = function(value) { + return moment(value).isValid(); +}; + +export { date }; diff --git a/assets/vue/views/course/Create.vue b/assets/vue/views/course/Create.vue new file mode 100644 index 0000000000..eb64ec4159 --- /dev/null +++ b/assets/vue/views/course/Create.vue @@ -0,0 +1,46 @@ + + + diff --git a/assets/vue/views/course/List.vue b/assets/vue/views/course/List.vue new file mode 100644 index 0000000000..13ae973216 --- /dev/null +++ b/assets/vue/views/course/List.vue @@ -0,0 +1,122 @@ + + + diff --git a/assets/vue/views/course/Show.vue b/assets/vue/views/course/Show.vue new file mode 100644 index 0000000000..e54efa6133 --- /dev/null +++ b/assets/vue/views/course/Show.vue @@ -0,0 +1,119 @@ + + + diff --git a/assets/vue/views/course/Update.vue b/assets/vue/views/course/Update.vue new file mode 100644 index 0000000000..7eae281b60 --- /dev/null +++ b/assets/vue/views/course/Update.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/vue/views/coursecategory/Create.vue b/assets/vue/views/coursecategory/Create.vue new file mode 100644 index 0000000000..26f0fb3e4c --- /dev/null +++ b/assets/vue/views/coursecategory/Create.vue @@ -0,0 +1,45 @@ + + + diff --git a/assets/vue/views/coursecategory/List.vue b/assets/vue/views/coursecategory/List.vue new file mode 100644 index 0000000000..6076b4b75b --- /dev/null +++ b/assets/vue/views/coursecategory/List.vue @@ -0,0 +1,104 @@ + + + diff --git a/assets/vue/views/coursecategory/Show.vue b/assets/vue/views/coursecategory/Show.vue new file mode 100644 index 0000000000..a30bc1ab77 --- /dev/null +++ b/assets/vue/views/coursecategory/Show.vue @@ -0,0 +1,87 @@ + + + diff --git a/assets/vue/views/coursecategory/Update.vue b/assets/vue/views/coursecategory/Update.vue new file mode 100644 index 0000000000..d3c75603d4 --- /dev/null +++ b/assets/vue/views/coursecategory/Update.vue @@ -0,0 +1,61 @@ + + + diff --git a/assets/vue/vue.config.js b/assets/vue/vue.config.js new file mode 100644 index 0000000000..c87e954b0a --- /dev/null +++ b/assets/vue/vue.config.js @@ -0,0 +1,11 @@ +module.exports = { + pluginOptions: { + quasar: { + importStrategy: 'manual', + rtlSupport: false + } + }, + transpileDependencies: [ + 'quasar' + ] +} diff --git a/package.json b/package.json index 1deccc3a98..3dd7229964 100644 --- a/package.json +++ b/package.json @@ -3,31 +3,13 @@ "version": "2.0.0", "description": "E-learning and collaboration software", "license": "GPL-3.0", - "devDependencies": { - "@coreui/coreui": "^2.1.7", - "@fortawesome/fontawesome-free": "^5.11", - "@symfony/webpack-encore": "~0.28", - "babel-eslint": "^10.1.0", - "babel-preset-react": "^6.24.1", - "bootstrap": "^4.4", - "copy-webpack-plugin": "^5.0", - "eslint": "^6.8.0", - "eslint-loader": "^4.0.2", - "eslint-plugin-vue": "^6.2.2", - "filemanager-webpack-plugin": "^2.0.5", - "free-jqgrid": "https://github.com/chamilo/jqGrid.git#bs4", - "jquery": "^3.5", - "node-sass": "^4.9", - "popper.js": "^1.14.7", - "sass-loader": "^7.0", - "vue": "^2.6", - "vue-loader": "^15.7.0", - "vue-template-compiler": "^2.6.10" - }, "dependencies": { "@fancyapps/fancybox": "^3.5.7", + "apollo-boost": "^0.4.7", "axios": "^0.19.2", - "babel-polyfill": "^6.26.0", + "babel": "^6.23.0", + "babel-plugin-transform-builtin-extend": "^1.1.2", + "babel-preset-env": "^1.7.0", "blueimp-file-upload": "^10.0", "bootstrap-daterangepicker": "^3.0", "bootstrap-select": "^1.13.6", @@ -42,6 +24,7 @@ "flag-icon-css": "^3.0", "fs": "0.0.1-security", "fullcalendar": "^3.0", + "graphql": "^15.0.0", "highlight.js": "^9.0", "hljs": "^6.2", "image-map-resizer": "^1.0.10", @@ -53,10 +36,11 @@ "js-cookie": "^2.2.0", "jsplumb": "^2.12", "linkifyjs": "^2.1", + "lodash": "^4.17.15", "mathjax": "^2.7", "mediaelement": "^4.2", "mediaelement-plugins": "https://github.com/chamilo/mediaelement-plugins", - "moment": "^2.22", + "moment": "^2.25.3", "multiselect-two-sides": "^2.5.5", "mxgraph": "^4.0", "optimize-css-assets-webpack-plugin": "^5.0.3", @@ -65,17 +49,54 @@ "pretty-checkbox": "^3.0.3", "pwstrength-bootstrap": "^3.0.5", "qtip2": "^3.0.3", + "@quasar/extras": "^1.0.0", + "quasar": "^1.10.5", "readmore-js": "^2.2.1", + "router": "^1.3.5", "select2": "^4.0", "sweetalert2": "^9.5", "textcomplete": "^0.18.1", "timeago": "^1.6.7", "timepicker": "^1.11.14", "video.js": "^7.6.6", + "vue-apollo": "^3.0.3", + "vue-i18n": "^8.17.4", "vue-router": "^3.1.6", + "vuelidate": "^0.7.5", + "vuetify": "^2.2.27", + "vue": "^2.6", + "vue-loader": "^15.7.0", + "vue-template-compiler": "^2.6.10", "vuex": "^3.3.0", + "vuex-map-fields": "^1.4.0", "webcamjs": "^1.0", "webpack-jquery-ui": "^2.0.1", "xcolor": "https://github.com/infusion/jQuery-xcolor" + }, + "devDependencies": { + "@coreui/coreui": "^2.1.7", + "@fortawesome/fontawesome-free": "^5.11", + "@symfony/webpack-encore": "~0.28", + "babel-eslint": "^10.1.0", + "babel-preset-react": "^6.24.1", + "bootstrap": "^4.4", + "copy-webpack-plugin": "^5.0", + "deepmerge": "^4.2.2", + "eslint": "^6.8.0", + "eslint-loader": "^4.0.2", + "eslint-plugin-vue": "^6.2.2", + "fibers": "^5.0.0", + "filemanager-webpack-plugin": "^2.0.5", + "free-jqgrid": "https://github.com/chamilo/jqGrid.git#bs4", + "jquery": "^3.5", + "node-sass": "^4.9", + "popper.js": "^1.14.7", + "sass": "^1.26.5", + "sass-loader": "^7.0.2", + "@vue/cli-plugin-babel": "~4.3.0", + "@vue/cli-plugin-eslint": "~4.3.0", + "@vue/cli-service": "~4.3.0", + "vue-cli-plugin-quasar": "~2.0.2", + "webpack-notifier": "^1.6.0" } } diff --git a/webpack.config.js b/webpack.config.js index 170eefeefc..f8ad38b3f6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,16 +6,16 @@ const FileManagerPlugin = require('filemanager-webpack-plugin'); Encore .setOutputPath('public/build/') .setManifestKeyPrefix('public/build/') - .setPublicPath('../') + // .setPublicPath('../') + .setPublicPath('/build') .cleanupOutputBeforeBuild() + .enableBuildNotifications() .addEntry('app', './assets/js/app.js') + .addEntry('vue', './assets/vue/main.js') .addEntry('bootstrap', './assets/js/bootstrap.js') - .addEntry('exercise', './assets/js/exercise.js') - - .addEntry('free-jqgrid', './assets/js/free-jqgrid.js') - + // .addEntry('free-jqgrid', './assets/js/free-jqgrid.js') .addStyleEntry('css/app', './assets/css/app.scss') .addStyleEntry('css/bootstrap', './assets/css/bootstrap.scss') @@ -29,12 +29,21 @@ Encore .addStyleEntry('css/scorm', './assets/css/scorm.css') .enableSingleRuntimeChunk() + .enableIntegrityHashes() .enableSourceMaps(!Encore.isProduction()) - // .enableVersioning(Encore.isProduction()) .enableSassLoader() - .enableVueLoader() + .enableVueLoader(function(options) { + options.pluginOptions = { + quasar: { + importStrategy: 'manual', + rtlSupport: false + } + } + + options.transpileDependencies = ['quasar']; + }) .autoProvidejQuery() .copyFiles([ { @@ -63,6 +72,19 @@ Encore to: 'libs/mathjax/MathJax.js' }, ]) + // enable ESLint + // .addLoader({ + // enforce: 'pre', + // test: /\.(js|vue)$/, + // loader: 'eslint-loader', + // exclude: /node_modules/, + // options: { + // fix: true, + // emitError: false, + // emitWarning: true, + // + // }, + // }) ; Encore.addPlugin(new CopyWebpackPlugin([ @@ -102,18 +124,18 @@ themes.forEach(function (theme) { }); // Fix free-jqgrid languages files -Encore.addPlugin(new FileManagerPlugin({ - onEnd: { - move: [ - { - source: './public/public/build/free-jqgrid/', - destination: './public/build/free-jqgrid/' - } - ], - delete: [ - './public/public/' - ] - } -})); - -module.exports = Encore.getWebpackConfig(); +// Encore.addPlugin(new FileManagerPlugin({ +// onEnd: { +// move: [ +// { +// source: './public/public/build/free-jqgrid/', +// destination: './public/build/free-jqgrid/' +// } +// ], +// delete: [ +// './public/public/' +// ] +// } +// })); + +module.exports = Encore.getWebpackConfig(); \ No newline at end of file