aboutsummaryrefslogtreecommitdiff
path: root/resources/assets/javascripts
diff options
context:
space:
mode:
authorJan-Hendrik Willms <tleilax+studip@gmail.com>2024-12-04 15:24:25 +0000
committerJan-Hendrik Willms <tleilax+studip@gmail.com>2024-12-04 15:24:25 +0000
commitfac89b11bc20d86ec435c1b450ccc50219002ecf (patch)
tree6d779253179cd5813aa5ab315d4a0d106fbbfe4c /resources/assets/javascripts
parentd448125b9902919c070ce7aecbfdfe1b47feb3b5 (diff)
update vue2 -> vue3, fixes #3747
Closes #3747 Merge request studip/studip!3108
Diffstat (limited to 'resources/assets/javascripts')
-rw-r--r--resources/assets/javascripts/bootstrap/avatar.js6
-rw-r--r--resources/assets/javascripts/bootstrap/courseware.js36
-rw-r--r--resources/assets/javascripts/bootstrap/forms.js43
-rw-r--r--resources/assets/javascripts/bootstrap/mvv_difflog.js18
-rw-r--r--resources/assets/javascripts/bootstrap/oer.js21
-rw-r--r--resources/assets/javascripts/bootstrap/vue.js170
-rw-r--r--resources/assets/javascripts/chunk-loader.js10
-rw-r--r--resources/assets/javascripts/chunks/avatar.js0
-rw-r--r--resources/assets/javascripts/chunks/vue.js111
-rw-r--r--resources/assets/javascripts/jquery-bundle.js4
-rw-r--r--resources/assets/javascripts/lib/admission.js2
-rw-r--r--resources/assets/javascripts/lib/blubber.js11
-rw-r--r--resources/assets/javascripts/lib/datetime.js6
-rw-r--r--resources/assets/javascripts/lib/files.js14
-rw-r--r--resources/assets/javascripts/lib/gettext.ts91
-rw-r--r--resources/assets/javascripts/lib/personal_notifications.js11
-rw-r--r--resources/assets/javascripts/lib/reststate-client.js141
-rw-r--r--resources/assets/javascripts/lib/reststate-vuex.js522
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 };