diff options
| author | Jan-Hendrik Willms <tleilax+studip@gmail.com> | 2024-12-04 15:24:25 +0000 |
|---|---|---|
| committer | Jan-Hendrik Willms <tleilax+studip@gmail.com> | 2024-12-04 15:24:25 +0000 |
| commit | fac89b11bc20d86ec435c1b450ccc50219002ecf (patch) | |
| tree | 6d779253179cd5813aa5ab315d4a0d106fbbfe4c /resources/assets/javascripts | |
| parent | d448125b9902919c070ce7aecbfdfe1b47feb3b5 (diff) | |
update vue2 -> vue3, fixes #3747
Closes #3747
Merge request studip/studip!3108
Diffstat (limited to 'resources/assets/javascripts')
18 files changed, 967 insertions, 250 deletions
diff --git a/resources/assets/javascripts/bootstrap/avatar.js b/resources/assets/javascripts/bootstrap/avatar.js index 31d724d..9c1de1c 100644 --- a/resources/assets/javascripts/bootstrap/avatar.js +++ b/resources/assets/javascripts/bootstrap/avatar.js @@ -4,13 +4,13 @@ STUDIP.domReady(() => { avatarTypes.forEach((type) => { if (document.getElementById(`avatar-${type}-app`)) { Promise.all([ - STUDIP.loadChunk('avatar'), + STUDIP.loadChunk('vue'), import( /* webpackChunkName: "avatar-app" */ '@/vue/avatar-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, `#avatar-${type}-app`); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, `#avatar-${type}-app`); }); } }); diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js index b90cc45..72503e0 100644 --- a/resources/assets/javascripts/bootstrap/courseware.js +++ b/resources/assets/javascripts/bootstrap/courseware.js @@ -6,8 +6,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-shelf-app" */ '@/vue/courseware-shelf-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-shelf-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-shelf-app'); }); } @@ -18,8 +18,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-index-app" */ '@/vue/courseware-index-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-index-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-index-app'); }); } @@ -30,8 +30,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-activities-app" */ '@/vue/courseware-activities-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-activities-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-activities-app'); }); } @@ -42,8 +42,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-tasks-app" */ '@/vue/courseware-tasks-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-tasks-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-tasks-app'); }); } @@ -54,8 +54,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-content-bookmark-app" */ '@/vue/courseware-content-bookmark-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-content-bookmark-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-content-bookmark-app'); }); } @@ -66,8 +66,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-content-bookmark-app" */ '@/vue/courseware-admin-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-admin-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-admin-app'); }); } @@ -78,8 +78,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-public-app" */ '@/vue/courseware-public-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-public-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-public-app'); }); } @@ -90,8 +90,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-content-releases-app" */ '@/vue/courseware-content-releases-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-content-releases-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-content-releases-app'); }); } @@ -102,8 +102,8 @@ STUDIP.domReady(() => { /* webpackChunkName: "courseware-comments-app" */ '@/vue/courseware-comments-app.js' ), - ]).then(([{ createApp }, { default: mountApp }]) => { - return mountApp(STUDIP, createApp, '#courseware-comments-app'); + ]).then(([{ createApp, store }, { default: mountApp }]) => { + return mountApp(STUDIP, createApp, store, '#courseware-comments-app'); }); } diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js index e159699..bd863eb 100644 --- a/resources/assets/javascripts/bootstrap/forms.js +++ b/resources/assets/javascripts/bootstrap/forms.js @@ -1,5 +1,6 @@ -import { $gettext, $gettextInterpolate } from '../lib/gettext'; +import { $gettext } from '../lib/gettext'; import Report from '../lib/report.ts'; +import Dialog from "../lib/dialog"; // Allow fieldsets to collapse $(document).on( @@ -242,12 +243,13 @@ function createSelect2(element) { } STUDIP.ready(function () { - let forms = window.document.querySelectorAll('form.default.studipform:not(.vueified)'); + let forms = window.document.querySelectorAll('.studipform:not(.vueified)'); if (forms.length > 0) { STUDIP.Vue.load().then(({createApp}) => { forms.forEach(f => { - createApp({ - el: f, + f.classList.add('vueified'); + + const app = createApp({ data() { let params = JSON.parse(f.dataset.inputs); params.STUDIPFORM_REQUIRED = f.dataset.required ? JSON.parse(f.dataset.required) : []; @@ -343,9 +345,9 @@ STUDIP.ready(function () { note.description = $(this).data('validation_requirement'); } if (this.validity.tooShort) { - note.description = $gettextInterpolate( - $gettext('Geben Sie mindestens %{min} Zeichen ein.'), - {min: this.minLength} + note.description = $gettext( + 'Geben Sie mindestens %{min} Zeichen ein.', + { min: this.minLength } ); } if (this.validity.valueMissing) { @@ -353,9 +355,9 @@ STUDIP.ready(function () { note.description = $gettext('Dieses Feld muss ausgewählt sein.'); } else { if (this.minLength > 0) { - note.description = $gettextInterpolate( - $gettext('Hier muss ein Wert mit mindestens %{min} Zeichen eingetragen werden.'), - {min: this.minLength} + note.description = $gettext( + 'Hier muss ein Wert mit mindestens %{min} Zeichen eingetragen werden.', + { min: this.minLength } ); } else { note.description = $gettext('Hier muss ein Wert eingetragen werden.'); @@ -419,10 +421,19 @@ STUDIP.ready(function () { return orderedNotes; } }, - mounted () { - $(this.$el).addClass("vueified"); + mounted() { + if (this.$el.closest('.ui-dialog')) { + const cancelButton = this.$el.querySelector('footer .button.cancel:last-of-type'); + if (cancelButton) { + cancelButton.addEventListener('click', (e) => { + Dialog.close(); + e.preventDefault(); + }) + } + } } }); + app.mount(f); }); }); } @@ -435,12 +446,8 @@ STUDIP.ready(function () { if (simple_vue_items.length > 0) { STUDIP.Vue.load().then(({createApp}) => { simple_vue_items.forEach(f => { - createApp({ - el: f, - mounted() { - this.$el.classList.add('vueified'); - } - }); + f.classList.add('vueified'); + createApp().mount(f); }); }); } diff --git a/resources/assets/javascripts/bootstrap/mvv_difflog.js b/resources/assets/javascripts/bootstrap/mvv_difflog.js index 8ade918..0d970ec 100644 --- a/resources/assets/javascripts/bootstrap/mvv_difflog.js +++ b/resources/assets/javascripts/bootstrap/mvv_difflog.js @@ -1,4 +1,4 @@ -import { $gettext, $gettextInterpolate } from '../lib/gettext'; +import { $gettext } from '../lib/gettext'; STUDIP.domReady(() => { $('del.diffdel').each(function() { @@ -44,8 +44,8 @@ STUDIP.domReady(() => { senddata, function(data) { if (data) { - var info = $gettextInterpolate( - $gettext('Entfernt von %{user} am %{time}'), + var info = $gettext( + 'Entfernt von %{user} am %{time}', data ); del.attr('title', info); @@ -140,8 +140,8 @@ STUDIP.domReady(() => { senddata, function(data) { if (data) { - var info = $gettextInterpolate( - $gettext('Änderung durch %{user} am %{time}'), + var info = $gettext( + 'Änderung durch %{user} am %{time}', data ); ins.attr('title', info); @@ -175,8 +175,8 @@ STUDIP.domReady(() => { ); function onSuccess(data) { if (data) { - var info = $gettextInterpolate( - $gettext('Hinzugefügt von %{user} am %{time}'), + var info = $gettext( + 'Hinzugefügt von %{user} am %{time}', data ); curtable.attr('title', info); @@ -210,8 +210,8 @@ STUDIP.domReady(() => { ); function onSuccess(data) { if (data) { - var info = $gettextInterpolate( - $gettext('Entfernt von %{user} am %{time}'), + var info = $gettext( + 'Entfernt von %{user} am %{time}', data ); curtable.attr('title', info); diff --git a/resources/assets/javascripts/bootstrap/oer.js b/resources/assets/javascripts/bootstrap/oer.js index 2b47149..0854e8b 100644 --- a/resources/assets/javascripts/bootstrap/oer.js +++ b/resources/assets/javascripts/bootstrap/oer.js @@ -1,5 +1,3 @@ -import Quicksearch from '../../../vue/components/Quicksearch.vue'; - STUDIP.domReady(() => { if (jQuery(".oer_search").length) { STUDIP.OER.initSearch(); @@ -55,16 +53,15 @@ STUDIP.ready(() => { if ($('.oercampus_editmaterial').length) { STUDIP.Vue.load().then(({createApp}) => { - STUDIP.OER.EditApp = createApp({ - el: '.oercampus_editmaterial', + const app = createApp({ data() { return { name: $('.oercampus_editmaterial input.oername').val(), - logo_url: $('.oercampus_editmaterial .logo_file').data("oldurl"), - customlogo: $('.oercampus_editmaterial .logo_file').data("customlogo"), + logo_url: $('.oercampus_editmaterial .logo_file').data("oldurl") ?? null, + customlogo: $('.oercampus_editmaterial .logo_file').data("customlogo") == '1', filename: $('.oercampus_editmaterial .file.drag-and-drop').data("filename"), filesize: $('.oercampus_editmaterial .file.drag-and-drop').data("filesize"), - tags: $('.oercampus_editmaterial .oer_tags').data("defaulttags"), + tags: $('.oercampus_editmaterial .oer_tags').data("defaulttags") ?? [], minimumTags: 5 }; }, @@ -87,10 +84,9 @@ STUDIP.ready(() => { }, editImage: function (event) { let reader = new FileReader(); - let vue = this; - reader.addEventListener("load", function () { - vue.logo_url = reader.result; - vue.customlogo = true; + reader.addEventListener("load", () => { + this.logo_url = reader.result; + this.customlogo = true; }, false); reader.readAsDataURL( event.target.files.length > 0 @@ -137,8 +133,9 @@ STUDIP.ready(() => { return result; } }, - components: { Quicksearch } }); + app.mount('.oercampus_editmaterial'); + STUDIP.OER.EditApp = app; }); } }); diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js index 513c796..4b1ac6d 100644 --- a/resources/assets/javascripts/bootstrap/vue.js +++ b/resources/assets/javascripts/bootstrap/vue.js @@ -1,5 +1,37 @@ +import { defineAsyncComponent } from 'vue'; + +function attachComponents(app, configuredComponents) { + configuredComponents.forEach(component => { + const name = component.split('/').reverse()[0]; + app.component(name, defineAsyncComponent(() => { + const temp = import(`../../../vue/components/${component}.vue`); + temp.then(({default: c}) => { + const mounted = c.mounted ?? null; + c.mounted = function (...args) { + if ( + this.$el instanceof Element + && this.$el.closest('.studip-dialog') + && this.$el.querySelector('[data-dialog-button]') + ) { + this.$el.closest('.studip-dialog') + .querySelector('.ui-dialog-buttonpane') + .remove(); + } + if (mounted) { + mounted.call(this, args); + } + }; + return c; + }) + return temp; + })); + }); +} + STUDIP.ready(() => { - document.querySelectorAll('[data-vue-app]:not([data-vue-app-created])').forEach((node) => { + document.querySelectorAll('[data-vue-app]:not([data-vue-app-created])').forEach(async (node) => { + node.dataset.vueAppCreated = 'true'; + const config = Object.assign( { components: [], @@ -9,96 +41,70 @@ STUDIP.ready(() => { JSON.parse(node.dataset.vueApp) ); - let components = {}; - config.components.forEach(component => { - const name = component.split('/').reverse()[0]; - components[name] = () => { - // TODO: I wonder if this works with Vue3 + const { createApp, store } = await STUDIP.Vue.load(); - const temp = import(`../../../vue/components/${component}.vue`); - temp.then(({default: c}) => { - const mounted = c.mounted ?? null; - c.mounted = function (...args) { - if ( - this.$el instanceof Element - && this.$el.closest('.studip-dialog') - && this.$el.querySelector('[data-dialog-button]') - ) { - this.$el.closest('.studip-dialog') - .querySelector('.ui-dialog-buttonpane') - .remove(); - } - if (mounted) { - mounted.call(this, args); - } - }; - return c; - }) - return temp; - }; - }); + const promises = [Promise.resolve()]; - STUDIP.Vue.load().then(({createApp, store, Vue}) => { - const promises = [Promise.resolve()]; + for (const [index, name] of Object.entries(config.stores)) { + promises.push( + import(`../../../vue/store/${name}.js`).then(storeConfig => { + store.registerModule(index, storeConfig.default); - for (const [index, name] of Object.entries(config.stores)) { - promises.push( - import(`../../../vue/store/${name}.js`).then(storeConfig => { - store.registerModule(index, storeConfig.default); + const dataElement = document.getElementById(`vue-store-data-${index}`); + if (dataElement) { + const data = JSON.parse(dataElement.innerText); + Object.keys(data).forEach(command => { + store.commit(`${index}/${command}`, data[command]); + }); - const dataElement = document.getElementById(`vue-store-data-${index}`); - if (dataElement) { - const data = JSON.parse(dataElement.innerText); - Object.keys(data).forEach(command => { - store.commit(`${index}/${command}`, data[command]); - }); + dataElement.remove(); + } + }) + ); + } - dataElement.remove(); - } - }) - ); - } + const plugins = []; + for (const [plugin, filename] of Object.entries(config.plugins)) { + promises.push( + import(`../../../vue/plugins/${filename}.js`) + .then((temp) => plugins.push(temp[plugin])) + ); + } - for (const [plugin, filename] of Object.entries(config.plugins)) { - promises.push( - import(`../../../vue/plugins/${filename}.js`) - .then((temp) => Vue.use(temp[plugin], { store })) - ); - } + await Promise.all(promises); - Promise.all(promises).then(() => { - createApp({ - components, - store, + const app = createApp({ + store, - beforeCreate() { - STUDIP.Vue.emit('VueAppWillCreate', this); - }, - created() { - STUDIP.Vue.emit('VueAppDidCreate', this); - }, - beforeMount() { - STUDIP.Vue.emit('VueAppWillMount', this); - }, - mounted() { - STUDIP.Vue.emit('VueAppDidMount', this); - }, - beforeUpdate() { - STUDIP.Vue.emit('VueAppWillUpdate', this); - }, - updated() { - STUDIP.Vue.emit('VueAppDidUpdate', this); - }, - beforeDestroy() { - STUDIP.Vue.emit('VueAppWillDestroy', this); - }, - destroyed() { - STUDIP.Vue.emit('VueAppDidDestroy', this); - }, - }).$mount(node); - }); + beforeCreate() { + STUDIP.Vue.emit('VueAppWillCreate', this); + }, + created() { + STUDIP.Vue.emit('VueAppDidCreate', this); + }, + beforeMount() { + STUDIP.Vue.emit('VueAppWillMount', this); + }, + mounted() { + STUDIP.Vue.emit('VueAppDidMount', this); + }, + beforeUpdate() { + STUDIP.Vue.emit('VueAppWillUpdate', this); + }, + updated() { + STUDIP.Vue.emit('VueAppDidUpdate', this); + }, + beforeUnmount() { + STUDIP.Vue.emit('VueAppWillUnmount', this); + }, + unmounted() { + STUDIP.Vue.emit('VueAppDidUnmount', this); + }, }); - node.dataset.vueAppCreated = 'true'; + attachComponents(app, config.components); + plugins.forEach(plugin => app.use(plugin, { store })) + + app.mount(node); }); }); diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js index 995b35f..8cb692c 100644 --- a/resources/assets/javascripts/chunk-loader.js +++ b/resources/assets/javascripts/chunk-loader.js @@ -31,16 +31,6 @@ export const loadChunk = function (chunk, { silent = false } = {}) { ]).then(([Vue]) => Vue); break; - case 'avatar': - promise = Promise.all([ - STUDIP.loadChunk('vue'), - import( - /* webpackChunkName: "avatar" */ - './chunks/avatar' - ), - ]).then(([Vue]) => Vue); - break; - case 'code-highlight': promise = import( /* webpackChunkName: "code-highlight" */ diff --git a/resources/assets/javascripts/chunks/avatar.js b/resources/assets/javascripts/chunks/avatar.js deleted file mode 100644 index e69de29..0000000 --- a/resources/assets/javascripts/chunks/avatar.js +++ /dev/null diff --git a/resources/assets/javascripts/chunks/vue.js b/resources/assets/javascripts/chunks/vue.js index 8d506a1..80b111e 100644 --- a/resources/assets/javascripts/chunks/vue.js +++ b/resources/assets/javascripts/chunks/vue.js @@ -1,70 +1,89 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import Router from "vue-router"; -import eventBus from '../lib/event-bus.ts'; -import GetTextPlugin from 'vue-gettext'; -import { getLocale, getVueConfig } from '../lib/gettext'; +import { createApp as vueCreateApp } from 'vue'; +import { createStore as vuexCreateStore } from 'vuex'; +import eventBus from '../lib/event-bus'; +import gettext from '../lib/gettext'; import PortalVue from 'portal-vue'; import BaseComponents from '../../../vue/base-components.js'; import BaseDirectives from "../../../vue/base-directives.js"; import StudipStore from "../../../vue/store/StudipStore.js"; -import CKEditor from '@ckeditor/ckeditor5-vue2'; +import { resourceModule } from '@/assets/javascripts/lib/reststate-vuex.js'; +import axios from 'axios'; -// Setup gettext -Vue.use(GetTextPlugin, getVueConfig()); -eventBus.on('studip:set-locale', (locale) => { - Vue.config.language = locale; -}) +import CKEditor from '@ckeditor/ckeditor5-vue'; -// Register global components and directives -registerGlobalComponents(); -registerGlobalDirectives(); +const getHttpClient = () => + axios.create({ + baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true), + headers: { + 'Content-Type': 'application/vnd.api+json', + }, + }); -// Setup store and default Stud.IP store -Vue.use(Vuex); -const store = new Vuex.Store({}); +const httpClient = getHttpClient(); -store.registerModule('studip', StudipStore); +const createStore = () => { + const store = vuexCreateStore({}); -// Setup router and PortalVue -Vue.use(Router); -Vue.use(PortalVue); + store.registerModule('studip', StudipStore); -// Define our own global mixin for Vue -Vue.mixin({ - methods: { - globalEmit(...args) { - eventBus.emit(...args); - }, - globalOn(...args) { - eventBus.on(...args); - }, - globalOff(...args) { - eventBus.off(...args); - }, - getStudipConfig: store.getters['studip/getConfig'] - }, -}); + STUDIP.jsonapi_schemas.forEach((name) => { + store.registerModule(name, resourceModule({ name, httpClient })); + }); -Vue.use(CKEditor); + return store; +} + +// Setup store +const store = createStore(); // Define createApp function -function createApp(options, ...args) { - Vue.config.language = getLocale(); - return new Vue({ store, ...options }, ...args); +function createApp(options = {}, ...args) { + const app = vueCreateApp({ store, ...options }, ...args); + + app.config.compilerOptions.whitespace = 'condense'; + + // Define our own global mixin for Vue + app.mixin({ + methods: { + globalEmit(...args) { + eventBus.emit(...args); + }, + globalOn(...args) { + eventBus.on(...args); + }, + globalOff(...args) { + eventBus.off(...args); + }, + getStudipConfig: store.getters['studip/getConfig'] + }, + }); + + app.use(CKEditor); + app.use(gettext); + app.use(PortalVue); + app.use(store); + + // Register global components and directives + registerGlobalComponents(app); + registerGlobalDirectives(app); + + if (options.el) { + app.mount(options.el); + } + return app; } // Define global registration functions for components and directives -function registerGlobalComponents() { +function registerGlobalComponents(app) { for (const [name, component] of Object.entries(BaseComponents)) { - Vue.component(name, component); + app.component(name, component); } } -function registerGlobalDirectives() { +function registerGlobalDirectives(app) { for (const [name, directive] of Object.entries(BaseDirectives)) { - Vue.directive(name, directive); + app.directive(name, directive); } } -export { Vue, createApp, eventBus, store }; +export { createApp, eventBus, store, httpClient }; diff --git a/resources/assets/javascripts/jquery-bundle.js b/resources/assets/javascripts/jquery-bundle.js index bd16422..bfa9eef 100644 --- a/resources/assets/javascripts/jquery-bundle.js +++ b/resources/assets/javascripts/jquery-bundle.js @@ -1,6 +1,6 @@ -import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery'; +import $ from 'expose-loader?exposes=$,jQuery!jquery'; -import { setLocale } from './lib/gettext'; + import { setLocale } from './lib/gettext'; import 'jquery-ui/ui/widget.js'; import 'jquery-ui/ui/position.js'; diff --git a/resources/assets/javascripts/lib/admission.js b/resources/assets/javascripts/lib/admission.js index 4b13511..992c8be 100644 --- a/resources/assets/javascripts/lib/admission.js +++ b/resources/assets/javascripts/lib/admission.js @@ -23,7 +23,7 @@ const Admission = { getCourses: function(targetUrl) { var courseFilter = $('input[name="course_filter"]').val(); - if (courseFilter == '') { + if (courseFilter === '') { courseFilter = '%%%'; } var data = { diff --git a/resources/assets/javascripts/lib/blubber.js b/resources/assets/javascripts/lib/blubber.js index 4e724b4..783abe1 100644 --- a/resources/assets/javascripts/lib/blubber.js +++ b/resources/assets/javascripts/lib/blubber.js @@ -1,3 +1,5 @@ +import { resolveComponent, h } from "vue"; + const Blubber = { init() { const blubberPage = document.querySelector('#blubber-index, #messenger-course, .blubber_panel.vueinstance'); @@ -18,12 +20,13 @@ const Blubber = { function connectBlubber(blubberPanel, componentName) { return Promise.all([window.STUDIP.Vue.load(), Blubber.plugin()]).then( ([{ Vue, createApp, store }, BlubberPlugin]) => { - Vue.use(BlubberPlugin, { store }); const { initialThreadId, search } = blubberPanel.dataset; - return createApp({ - el: blubberPanel, - render: (h) => h(Vue.component(componentName), { props: { initialThreadId, search } }), + const app = createApp({ + render: () => h(resolveComponent(componentName), { initialThreadId, search }), }); + app.use(BlubberPlugin, { store }); + app.mount(blubberPanel); + return app; } ); } diff --git a/resources/assets/javascripts/lib/datetime.js b/resources/assets/javascripts/lib/datetime.js index 50f91f2..04d7260 100644 --- a/resources/assets/javascripts/lib/datetime.js +++ b/resources/assets/javascripts/lib/datetime.js @@ -1,4 +1,4 @@ -import { $gettext, $gettextInterpolate } from "./gettext.ts"; +import { $gettext } from "./gettext.ts"; const DateTime = { @@ -43,8 +43,8 @@ const DateTime = { return $gettext('Jetzt'); } if (now - date < 2 * 60 * 60 * 1000) { - return $gettextInterpolate( - $gettext('Vor %{ minutes } Minuten'), + return $gettext( + 'Vor %{ minutes } Minuten', {minutes: Math.floor((now - date) / (1000 * 60))} ); } diff --git a/resources/assets/javascripts/lib/files.js b/resources/assets/javascripts/lib/files.js index d05112d..39bd893 100644 --- a/resources/assets/javascripts/lib/files.js +++ b/resources/assets/javascripts/lib/files.js @@ -1,6 +1,7 @@ import { $gettext } from './gettext'; import Dialog from './dialog.js'; import FilesTable from '../../../vue/components/FilesTable.vue'; +import { h } from 'vue'; const Files = { init () { @@ -8,8 +9,7 @@ const Files = { && jQuery("#files_table_form").length) { STUDIP.Vue.load().then(({createApp}) => { - this.filesapp = createApp({ - el: "#content", + const app = createApp({ data() { return { files: jQuery("#files_table_form").data("files") || [], @@ -27,6 +27,9 @@ const Files = { } return false; }, + pushFile(file) { + this.files.push(file); + }, removeFile(id) { this.files = this.files.filter(file => file.id != id) }, @@ -36,14 +39,15 @@ const Files = { }); } }, - components: { FilesTable, }, updated () { this.onUpdated(); }, created () { this.onUpdated(); - } + }, }); + app.component('files-table', FilesTable); + this.filesapp = app.mount('#files_table_form'); }); } @@ -258,7 +262,7 @@ const Files = { } } if (insert) { - STUDIP.Files.filesapp.files.push(value); + STUDIP.Files.filesapp.pushFile(value); } }); $(document).trigger('refresh-handlers'); diff --git a/resources/assets/javascripts/lib/gettext.ts b/resources/assets/javascripts/lib/gettext.ts index 23daaaa..c63831e 100644 --- a/resources/assets/javascripts/lib/gettext.ts +++ b/resources/assets/javascripts/lib/gettext.ts @@ -1,4 +1,4 @@ -import { translate } from 'vue-gettext'; +import { createGettext, LanguageData } from 'vue3-gettext'; import * as defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json'; import eventBus from './event-bus'; @@ -15,73 +15,92 @@ interface InstalledLanguages { [key: string]: InstalledLanguage; } -type TranslationDict = StringDict; +type Translation = LanguageData; -interface TranslationDicts { - [key: string]: TranslationDict | null; -} +type Translations = { + [language: string]: LanguageData; +}; const DEFAULT_LANG = 'de_DE'; const DEFAULT_LANG_NAME = 'Deutsch'; const state = getInitialState(); -const $gettext = translate.gettext.bind(translate); -const $ngettext = translate.ngettext.bind(translate); -const $gettextInterpolate = translate.gettextInterpolate.bind(translate); - -export { $gettext, $ngettext, $gettextInterpolate, translate, getLocale, setLocale, getVueConfig }; +const gettext = createGettext({ + availableLanguages: getAvailableLanguages(), + defaultLanguage: state.locale, + silent: false, + translations: { + [DEFAULT_LANG]: {} + }, + mutedLanguages: [DEFAULT_LANG], + setGlobalProperties: true, + globalProperties: { + language: ['$language'], + gettext: ['$gettext'], + pgettext: ['$pgettext'], + ngettext: ['$ngettext'], + npgettext: ['$npgettext'], + interpolate: ['$gettextInterpolate'], + }, + provideDirective: true, + provideComponent: true, +}); + +setLocale(state.locale); + +export default gettext; + +async function updateTranslations() { + let translations: Translations = {}; + + for (const [key, value] of Object.entries(getAvailableLanguages())) { + if (state.locale === key) { + const translation = await getTranslations(key); + translations[key] = translation; + } + } + gettext.translations = translations; +} -function getLocale() { +export function getLocale() { return state.locale; } -async function setLocale(locale = getInitialLocale()) { +export async function setLocale(locale = getInitialLocale()) { if (!(locale in getInstalledLanguages())) { throw new Error('Invalid locale: ' + locale); } state.locale = locale; if (state.translations[state.locale] === null) { - const translations: TranslationDict = await getTranslations(state.locale); + const translations: Translation = await getTranslations(state.locale); state.translations[state.locale] = translations; } - translate.initTranslations(state.translations, { - getTextPluginMuteLanguages: [DEFAULT_LANG], - getTextPluginSilent: false, - language: state.locale, - silent: false, - }); + updateTranslations(); eventBus.emit('studip:set-locale', state.locale); } -function getVueConfig() { - const availableLanguages = Object.entries(getInstalledLanguages()).reduce((memo, [lang, { name }]) => { +function getAvailableLanguages() { + return Object.entries(getInstalledLanguages()).reduce((memo, [lang, { name }]) => { memo[lang] = name; return memo; }, {} as StringDict); - - return { - availableLanguages, - defaultLanguage: DEFAULT_LANG, - muteLanguages: [DEFAULT_LANG], - silent: false, - translations: state.translations, - }; } + function getInitialState() { - const translations: TranslationDicts = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { - memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null; + const translations: Translations = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { + memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : ''; return memo; - }, {} as TranslationDicts); + }, {} as Translations); return { - locale: DEFAULT_LANG, + locale: getInitialLocale(), translations, }; } @@ -100,7 +119,7 @@ function getInstalledLanguages(): InstalledLanguages { return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } }; } -async function getTranslations(locale: string): Promise<TranslationDict> { +async function getTranslations(locale: string): Promise<Translation> { try { const language = locale.split(/[_-]/)[0]; const translation = await import(`../../../../locale/${language}/LC_MESSAGES/js-resources.json`); @@ -112,3 +131,7 @@ async function getTranslations(locale: string): Promise<TranslationDict> { return {}; } } + +export const $gettext = gettext.$gettext; +export const $ngettext = gettext.$ngettext; +export const $gettextInterpolate = gettext.interpolate; diff --git a/resources/assets/javascripts/lib/personal_notifications.js b/resources/assets/javascripts/lib/personal_notifications.js index d05fbaa..3489088 100644 --- a/resources/assets/javascripts/lib/personal_notifications.js +++ b/resources/assets/javascripts/lib/personal_notifications.js @@ -1,7 +1,7 @@ import Favico from 'favico.js'; import Cache from './cache.js'; import PageLayout from './page_layout.js'; -import { $gettextInterpolate, $ngettext } from './gettext'; +import { $ngettext } from './gettext'; var stack = {}; var audio_notification = false; @@ -189,8 +189,13 @@ const PersonalNotifications = { } if (old_count !== count) { $('#notification_marker .count').text(count); - let notification_text = $ngettext('%{ count } Benachrichtigung', '%{ count } Benachrichtigungen', count); - $('#notification_marker').attr('title', $gettextInterpolate(notification_text, {count: count})); + let notification_text = $ngettext( + '%{ count } Benachrichtigung', + '%{ count } Benachrichtigungen', + count, + { count } + ); + $('#notification_marker').attr('title', notification_text); updateFavicon(count); $('#notification-container .mark-all-as-read').toggleClass('invisible', count < 2); } diff --git a/resources/assets/javascripts/lib/reststate-client.js b/resources/assets/javascripts/lib/reststate-client.js new file mode 100644 index 0000000..5514d15 --- /dev/null +++ b/resources/assets/javascripts/lib/reststate-client.js @@ -0,0 +1,141 @@ +function filterQueryString(obj) { + return Object.keys(obj) + .map(k => `filter[${k}]=${encodeURIComponent(obj[k])}`) + .join('&'); +} + +const getOptionsQuery = (optionsObject = {}) => + Object.keys(optionsObject) + .filter(k => typeof optionsObject[k] !== 'undefined') + .map(k => `${k}=${encodeURIComponent(optionsObject[k])}`) + .join('&'); + +const relatedResourceUrl = ({ parent, relationship }) => { + const builtUrl = `${parent.type}/${parent.id}/${relationship}`; + + if ( + parent.relationships + && Object.keys(parent.relationships).includes(relationship) + ) { + return parent.relationships[relationship].links?.related ?? builtUrl; + } + return builtUrl; +}; + +const extractData = response => response.data; + +const extractErrorResponse = error => { + if (error && error.response) { + throw error.response; + } else { + throw error; + } +}; + +class Resource { + constructor({ name, httpClient }) { + this.name = name; + this.api = httpClient; + } + + all({ options = {} } = {}) { + let url; + + if (options.url) { + ({ url } = options); + } else { + url = `${this.name}?${getOptionsQuery(options)}`; + } + + return this.api.get(url).then(extractData).catch(extractErrorResponse); + } + + find({ id, options } = {}) { + const url = `${this.name}/${id}?${getOptionsQuery(options)}`; + + return this.api.get(url).then(extractData).catch(extractErrorResponse); + } + + where({ filter, options } = {}) { + const queryString = filterQueryString(filter); + return this.api + .get(`${this.name}?${queryString}&${getOptionsQuery(options)}`) + .then(extractData) + .catch(extractErrorResponse); + } + + related({ parent, relationship = this.name, options }) { + const baseUrl = relatedResourceUrl({ parent, relationship }); + const url = `${baseUrl}?${getOptionsQuery(options)}`; + return this.api.get(url).then(extractData).catch(extractErrorResponse); + } + + create(partialRecord) { + const record = Object.assign({}, partialRecord, { type: this.name }); + const requestData = { data: record }; + return this.api + .post(`${this.name}`, requestData) + .then(extractData) + .catch(extractErrorResponse); + } + + createRelated(partialRecord) { + const record = Object.assign({}, partialRecord, { type: this.name }); + const requestData = { data: record }; + return this.api + .post(`${this.name}`, requestData) + .then(extractData) + .catch(extractErrorResponse); + } + + updateRelationships(parent, relationship, records) { + // https://jsonapi.org/format/#crud-updating-to-many-relationships + const requestData = { data: records }; + return this.api + .patch( + `${parent.type}/${parent.id}/relationships/${relationship}`, + requestData, + ) + .then(extractData) + .catch(extractErrorResponse); + } + + createRelationships(parent, relationship, records) { + // https://jsonapi.org/format/#crud-updating-to-many-relationships + const requestData = { data: records }; + return this.api + .post( + `${parent.type}/${parent.id}/relationships/${relationship}`, + requestData, + ) + .then(extractData) + .catch(extractErrorResponse); + } + + removeRelationships(parent, relationship, records) { + // https://jsonapi.org/format/#crud-updating-to-many-relationships + const requestData = { data: records }; + return this.api + .delete( + `${parent.type}/${parent.id}/relationships/${relationship}`, + requestData, + ) + .then(extractData) + .catch(extractErrorResponse); + } + + update(record) { + // http://jsonapi.org/faq/#wheres-put + const requestData = { data: record }; + return this.api + .patch(`${this.name}/${record.id}`, requestData) + .then(extractData) + .catch(extractErrorResponse); + } + + delete({ id }) { + return this.api.delete(`${this.name}/${id}`).catch(extractErrorResponse); + } +} + +export default Resource; diff --git a/resources/assets/javascripts/lib/reststate-vuex.js b/resources/assets/javascripts/lib/reststate-vuex.js new file mode 100644 index 0000000..a3dd6ca --- /dev/null +++ b/resources/assets/javascripts/lib/reststate-vuex.js @@ -0,0 +1,522 @@ +import ResourceClient from './reststate-client.js'; +import { isEqual } from 'lodash'; + +const STATUS_INITIAL = 'INITIAL'; +const STATUS_LOADING = 'LOADING'; +const STATUS_ERROR = 'ERROR'; +const STATUS_SUCCESS = 'SUCCESS'; + +const storeRecord = records => newRecord => { + const existingRecord = records.find(r => r.id === newRecord.id); + if (existingRecord) { + Object.assign(existingRecord, newRecord); + } else { + records.push(newRecord); + } +}; + +const getResourceIdentifier = resource => { + if (!resource) { + return resource; + } + + return { + type: resource.type, + id: resource.id, + }; +}; + +const getRelationshipType = relationship => { + const data = Array.isArray(relationship.data) + ? relationship.data[0] + : relationship.data; + + return data && data.type; +}; + +const storeIncluded = ({ commit, dispatch }, result) => { + if (result.included) { + // store the included records + result.included.forEach(relatedRecord => { + const action = `${relatedRecord.type}/storeRecord`; + dispatch(action, relatedRecord, { root: true }); + }); + + // store the relationship for primary and secondary records + let allRecords = [...result.included]; + if (Array.isArray(result.data)) { + allRecords = [...allRecords, ...result.data]; + } else { + allRecords = [...allRecords, result.data]; + } + + allRecords.forEach(primaryRecord => { + if (primaryRecord.relationships) { + Object.keys(primaryRecord.relationships).forEach(relationshipName => { + const relationship = primaryRecord.relationships[relationshipName]; + if (!relationship.data || relationship.data.length === 0) { + return; + } + + const type = getRelationshipType(relationship); + let relatedIds; + if (Array.isArray(relationship.data)) { + relatedIds = relationship.data.map( + relatedRecord => relatedRecord.id, + ); + } else { + ({ id: relatedIds } = relationship.data); + } + const options = { + relatedIds, + params: { + parent: getResourceIdentifier(primaryRecord), + relationship: relationshipName, + }, + }; + const action = `${type}/storeRelated`; + dispatch(action, options, { root: true }); + }); + } + }); + } +}; + +const matches = criteria => test => + Object.keys(criteria).every(key => isEqual(criteria[key], test[key])); + +const handleError = commit => errorResponse => { + commit('SET_STATUS', STATUS_ERROR); + commit('STORE_ERROR', errorResponse); + throw errorResponse; +}; + +const initialState = () => ({ + records: [], + related: [], + filtered: [], + page: [], + error: null, + status: STATUS_INITIAL, + links: {}, + lastCreated: null, + lastMeta: null, +}); + +const resourceModule = ({ name: resourceName, httpClient }) => { + const client = new ResourceClient({ name: resourceName, httpClient }); + + const getRelationshipIndex = params => { + const { parent, relationship = resourceName } = params; + const parentResourceIdentifier = getResourceIdentifier(parent); + + return { + parent: parentResourceIdentifier, + relationship, + }; + }; + + return { + namespaced: true, + + state: initialState, + + mutations: { + REPLACE_ALL_RECORDS: (state, records) => { + state.records = records; + }, + + REPLACE_ALL_RELATED: (state, related) => { + state.related = related; + }, + + SET_STATUS: (state, status) => { + state.status = status; + }, + + STORE_RECORD: (state, newRecord) => { + const { records } = state; + + storeRecord(records)(newRecord); + }, + + STORE_RECORDS: (state, newRecords) => { + const { records } = state; + + newRecords.forEach(storeRecord(records)); + }, + + STORE_PAGE: (state, records) => { + state.page = records.map(({ id }) => id); + }, + + STORE_META: (state, meta) => { + state.lastMeta = meta; + }, + + STORE_ERROR: (state, error) => { + state.error = error; + }, + + STORE_RELATED: (state, { relatedIds, params, resetRelated = true }) => { + const { related } = state; + const relationshipIndex = getRelationshipIndex(params); + const existingRecord = related.find(matches(relationshipIndex)); + if (existingRecord) { + if (resetRelated) { + existingRecord.relatedIds = relatedIds; + } else { + const ids = new Set([...existingRecord.relatedIds, ...relatedIds]) + existingRecord.relatedIds = [...ids]; + } + } else { + related.push(Object.assign({ relatedIds }, relationshipIndex)); + } + }, + + STORE_FILTERED: (state, { matchedIds, params }) => { + const { filtered } = state; + + const existingRecord = filtered.find(matches(params)); + if (existingRecord) { + existingRecord.matchedIds = matchedIds; + } else { + filtered.push(Object.assign({ matchedIds }, params)); + } + }, + + STORE_LAST_CREATED: (state, record) => { + state.lastCreated = record; + }, + + REMOVE_RECORD: (state, record) => { + state.records = state.records.filter(r => r.id !== record.id); + }, + + SET_LINKS: (state, links) => { + state.links = links || {}; + }, + + RESET_STATE: state => { + Object.assign(state, initialState()); + }, + }, + + actions: { + loadAll({ commit, dispatch }, { options } = {}) { + commit('SET_STATUS', STATUS_LOADING); + return client + .all({ options }) + .then(result => { + commit('SET_STATUS', STATUS_SUCCESS); + commit('REPLACE_ALL_RECORDS', result.data); + commit('STORE_META', result.meta); + storeIncluded({ commit, dispatch }, result); + }) + .catch(handleError(commit)); + }, + + loadById({ commit, dispatch }, { id, options }) { + commit('SET_STATUS', STATUS_LOADING); + return client + .find({ id, options }) + .then(results => { + commit('SET_STATUS', STATUS_SUCCESS); + commit('STORE_RECORD', results.data); + commit('STORE_META', results.meta); + storeIncluded({ commit, dispatch }, results); + }) + .catch(handleError(commit)); + }, + + loadWhere({ commit, dispatch }, params) { + const { filter, options } = params; + commit('SET_STATUS', STATUS_LOADING); + return client + .where({ filter, options }) + .then(results => { + commit('SET_STATUS', STATUS_SUCCESS); + const matches = results.data; + const matchedIds = matches.map(record => record.id); + commit('STORE_RECORDS', matches); + commit('STORE_FILTERED', { params, matchedIds }); + commit('STORE_META', results.meta); + storeIncluded({ commit, dispatch }, results); + }) + .catch(handleError(commit)); + }, + + loadPage({ commit, dispatch }, { options }) { + commit('SET_STATUS', STATUS_LOADING); + return client + .all({ options }) + .then(response => { + commit('SET_STATUS', STATUS_SUCCESS); + commit('STORE_RECORDS', response.data); + commit('STORE_PAGE', response.data); + commit('STORE_META', response.meta); + commit('SET_LINKS', response.links); + storeIncluded({ commit, dispatch }, response); + }) + .catch(handleError(commit)); + }, + + loadNextPage({ commit, state, dispatch }) { + const options = { + url: state.links.next, + }; + return client.all({ options }).then(response => { + commit('STORE_RECORDS', response.data); + commit('STORE_PAGE', response.data); + commit('SET_LINKS', response.links); + commit('STORE_META', response.meta); + storeIncluded({ commit, dispatch }, response); + }); + }, + + loadPreviousPage({ commit, state, dispatch }) { + const options = { + url: state.links.prev, + }; + return client.all({ options }).then(response => { + commit('STORE_RECORDS', response.data); + commit('STORE_PAGE', response.data); + commit('SET_LINKS', response.links); + commit('STORE_META', response.meta); + storeIncluded({ commit, dispatch }, response); + }); + }, + + loadRelated({ commit, dispatch }, params) { + const { parent, relationship = resourceName, options, resetRelated = true } = params; + commit('SET_STATUS', STATUS_LOADING); + const paramsToStore = { + ...params, + relationship, + }; + return client + .related({ parent, relationship, options }) + .then(results => { + commit('SET_STATUS', STATUS_SUCCESS); + const { id, type } = parent; + if (Array.isArray(results.data)) { + const relatedRecords = results.data; + const relatedIds = relatedRecords.map(record => record.id); + commit('STORE_RECORDS', relatedRecords); + commit('STORE_RELATED', { params: paramsToStore, relatedIds, resetRelated }); + } else { + const record = results.data; + const relatedIds = record.id; + commit('STORE_RECORDS', [record]); + commit('STORE_RELATED', { params: paramsToStore, relatedIds }); + } + commit('STORE_META', results.meta); + storeIncluded({ commit, dispatch }, results); + }) + .catch(handleError(commit)); + }, + + create({ commit }, recordData) { + return client.create(recordData).then(result => { + commit('STORE_RECORD', result.data); + commit('STORE_LAST_CREATED', result.data); + }); + }, + + update({ commit, dispatch, getters }, record) { + return client.update(record).then(() => { + const oldRecord = getters.byId({ id: record.id }); + + // remove old relationships first + if (oldRecord && oldRecord.relationships) { + for (const entry of Object.entries(oldRecord.relationships)) { + const [relationship, entity] = entry; + const type = getRelationshipType(entity); + const paramsToStore = { + relationship, + parent: getResourceIdentifier(oldRecord), + }; + + // we cannot update the related resource without a type + // this could possibly be very bad as we cannot remove existing + // relationships + if (type === null || type === undefined) { + continue; + } + + dispatch( + `${type}/storeRelated`, + { + params: paramsToStore, + relatedIds: null, + }, + { root: true }, + ); + } + } + + // save entity + commit('STORE_RECORD', record); + + // set new relationships + if (record.relationships) { + for (const relationship of Object.keys(record.relationships)) { + const relationshipObject = record.relationships[relationship]; + const { data } = relationshipObject; + const isNonEmptyArray = Array.isArray(data) && Boolean(data.length); + const isObject = Boolean(data && data.type && data.id); + + if (isNonEmptyArray || isObject) { + const paramsToStore = { + parent: getResourceIdentifier(record), + relationship, + }; + const type = getRelationshipType(relationshipObject); + let relatedIds; + if (Array.isArray(data)) { + relatedIds = data.map(record => record.id); + } else { + relatedIds = data.id; + } + dispatch( + `${type}/storeRelated`, + { + params: paramsToStore, + relatedIds, + }, + { root: true }, + ); + } + } + } + }); + }, + + delete({ commit }, record) { + return client.delete(record).then(() => { + commit('REMOVE_RECORD', record); + }); + }, + + storeRecord({ commit }, record) { + commit('STORE_RECORD', record); + }, + + storeRelated({ commit }, { relatedIds, params }) { + commit('STORE_RELATED', { + relatedIds, + params, + }); + }, + + removeRecord({ commit }, record) { + commit('REMOVE_RECORD', record); + }, + + resetState({ commit }) { + commit('RESET_STATE'); + }, + + addRelated({ commit, getters }, params) { + const { parent, relationship = resourceName, data } = params; + const relatedItems = getters.related(params).map(o => o.id); + const difference = data.filter(x => !relatedItems.includes(x)); + const records = difference.map(id => { + return { type: relationship, id }; + }); + return client.createRelationships(parent, relationship, records); + }, + + setRelated({ commit, dispatch }, params) { + const { parent, relationship = resourceName, data } = params; + return client.updateRelationships(parent, relationship, data).then(response => { + let relatedIds; + if (Array.isArray(data)) { + relatedIds = data.map(record => record.id); + } else if (data === null) { + relatedIds = null; + } else { + relatedIds = data.id; + } + commit('STORE_RELATED', { + params: { parent, relationship }, + relatedIds, + }); + }); + }, + + removeRelated({ commit, dispatch }, params) { + const { parent, relationship = resourceName, data } = params; + client.removeRelationships(parent, relationship, data); + let relatedIds; + if (Array.isArray(data)) { + relatedIds = data.map(record => record.id); + } else { + relatedIds = data.id; + } + commit('REMOVE_RELATED', { + params: { parent, relationship }, + relatedIds, + }); + }, + + removeAllRelated({ commit, dispatch }, params) { + const { parent, relationship = resourceName } = params; + client.updateRelationships(parent, relationship, []); + commit('REMOVE_RELATED', { + params: { parent, relationship }, + relatedIds: [], + }); + }, + }, + + getters: { + isLoading: state => state.status === STATUS_LOADING, + isError: state => state.status === STATUS_ERROR, + error: state => state.error, + hasPrevious: state => !!state.links.prev, + hasNext: state => !!state.links.next, + all: state => state.records, + lastCreated: state => state.lastCreated, + byId: state => ({ id }) => state.records.find(r => r.id == id), + lastMeta: state => state.lastMeta, + page: state => + state.page.map(id => state.records.find(record => record.id === id)), + where: state => params => { + const entry = state.filtered.find(matches(params)); + + if (!entry) { + return []; + } + + const ids = entry.matchedIds; + return ids.map(id => state.records.find(record => record.id === id)); + }, + related: state => params => { + const relationshipIndex = getRelationshipIndex(params); + const related = state.related.find(matches(relationshipIndex)); + + if (!related) { + return null; + } else if (Array.isArray(related.relatedIds)) { + const ids = related.relatedIds; + return ids + .map(id => state.records.find(record => record.id === id)) + .filter(record => record !== undefined); + } else { + const id = related.relatedIds; + return state.records.find(record => id === record.id); + } + }, + }, + }; +}; + +const mapResourceModules = ({ names, httpClient }) => + names.reduce( + (acc, name) => + Object.assign({ [name]: resourceModule({ name, httpClient }) }, acc), + {}, + ); + +export { resourceModule, mapResourceModules }; |
