aboutsummaryrefslogtreecommitdiff
path: root/resources/assets/javascripts/lib
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/lib
parentd448125b9902919c070ce7aecbfdfe1b47feb3b5 (diff)
update vue2 -> vue3, fixes #3747
Closes #3747 Merge request studip/studip!3108
Diffstat (limited to 'resources/assets/javascripts/lib')
-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
8 files changed, 748 insertions, 50 deletions
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 };