diff --git a/aleksis/core/assets/app/apollo.js b/aleksis/core/assets/app/apollo.js index 99f72137596e0cf32eb7f3fbb1c535aa02d5d960..5d0860f7c8ad5f5d81994684b353e3c824ea9cd9 100644 --- a/aleksis/core/assets/app/apollo.js +++ b/aleksis/core/assets/app/apollo.js @@ -1,5 +1,8 @@ +/* + * Configuration for Apollo provider, client, and caches. + */ + import { ApolloClient, HttpLink, from } from "@/apollo-boost"; -import VueApollo from "@/vue-apollo"; import { onError } from "@/apollo-link-error"; import { RetryLink } from "@/apollo-link-retry"; @@ -10,8 +13,17 @@ import gql from "@/graphql-tag"; import gqlSnackbarItems from "../components/app/snackbarItems.graphql"; +// Cache for GraphQL query results in memory and persistent across sessions const cache = new InMemoryCache(); +await persistCache({ + cache: cache, + storage: new LocalStorageWrapper(window.localStorage), +}); +/** + * Type definitions for Apollo/s local state management + * cf. https://www.apollographql.com/docs/react/local-state/local-state-management/ + */ const typeDefs = gql` type snackbarItem { id: ID! @@ -26,28 +38,27 @@ const typeDefs = gql` } type Mutation { - checkSnackbarItem(id: ID!): Boolean - setLoading(state: Boolean!): Boolean + toggleSnackbarItem(id: ID!): Boolean } `; +/** Resolvers for local state management */ const resolvers = { Mutation: { - // eslint-disable-next-line no-unused-vars - checkSnackbarItem: (_, { id }, { cache }) => { + /** Toggle snackbar item read or unread, given its ID */ + toggleSnackbarItem: (_, { id }, { cache }) => { const data = cache.readQuery({ query: gqlSnackbarItems }); const currentItem = data.snackbarItems.find((item) => item.id === id); currentItem.read = !currentItem.read; cache.writeQuery({ query: gqlSnackbarItems, data }); return currentItem.read; }, - // eslint-disable-next-line - setLoading: (_, { state }, {}) => { - return true; - }, }, }; +/** + * Utility function to add a snackbar on GraphQL errors. + */ function addErrorSnackbarItem(messageKey) { let uuid = crypto.randomUUID(); cache.writeQuery({ @@ -69,28 +80,32 @@ function addErrorSnackbarItem(messageKey) { }); } -const retryLink = new RetryLink(); - -const errorLink = onError(({ graphQLErrors, networkError }) => { - if (graphQLErrors) addErrorSnackbarItem("graphql.snackbar_error_message"); - - if (networkError) app.offline = true; -}); - -const httpLink = new HttpLink({ - uri: window.location.origin + "/graphql/", -}); - -await persistCache({ - cache: cache, - storage: new LocalStorageWrapper(window.localStorage), -}); - +// Define Apollo links for handling query operations. +const links = [ + // Automatically retry failed queries + new RetryLink(), + // Add custom error handlers + onError(({ graphQLErrors, networkError }) => { + // Add a snackbar on all errors returned by the GraphQL endpoint + if (graphQLErrors) addErrorSnackbarItem("graphql.snackbar_error_message"); + // Set app offline globally on network errors + // This will cause the offline logic to kick in, starting a ping check or + // similar recovery strategies depending on the app/navigator state + if (networkError) app.offline = true; + }), + // Finally, the HTTP link to the real backend (Django) + new HttpLink({ + uri: window.location.origin + "/graphql/", + }), +]; + + +/** Upstream Apollo GraphQL client */ const apolloClient = new ApolloClient({ typeDefs, resolvers, cache, - link: from([retryLink, errorLink, httpLink]), + link: from(links), }); const apolloOpts = { diff --git a/aleksis/core/assets/app/dateTimeFormats.js b/aleksis/core/assets/app/dateTimeFormats.js index eb9131f979e96413e9f869255dc960022fb9d592..a835b238ccb56b0cdfc465f3b7ea2341e45322f8 100644 --- a/aleksis/core/assets/app/dateTimeFormats.js +++ b/aleksis/core/assets/app/dateTimeFormats.js @@ -1,3 +1,4 @@ +/** Date.time formats for VueI18n */ const dateTimeFormats = { en: { short: { diff --git a/aleksis/core/assets/app/i18n.js b/aleksis/core/assets/app/i18n.js index 4ff38f759889edaddaa1ef00c09f1c8605ddb131..ed50743eddf2d7fe306b9fe1116ef17fc914911d 100644 --- a/aleksis/core/assets/app/i18n.js +++ b/aleksis/core/assets/app/i18n.js @@ -1,3 +1,7 @@ +/* + * Configuration for VueI18n + */ + import dateTimeFormats from "./dateTimeFormats.js"; const i18nOpts = { diff --git a/aleksis/core/assets/app/router.js b/aleksis/core/assets/app/router.js index e7cb157b3227a70210458f09f453100aa243f61b..b1667866c17472759c9a589dfbe2cad0a9a961eb 100644 --- a/aleksis/core/assets/app/router.js +++ b/aleksis/core/assets/app/router.js @@ -1,3 +1,7 @@ +/* + * Configuration for Vue router + */ + import routes from "../routes.js"; const routerOpts = { diff --git a/aleksis/core/assets/app/vuetify.js b/aleksis/core/assets/app/vuetify.js index c92924918eaf6fbf0ff3aa79af20696bd2fdb545..e2d4439a6dac88f00af6d7ff57d8bda0b963ea40 100644 --- a/aleksis/core/assets/app/vuetify.js +++ b/aleksis/core/assets/app/vuetify.js @@ -1,3 +1,6 @@ +/* + * Configuration for Vuetify + */ import "@/@mdi/font/css/materialdesignicons.css"; import "@/vuetify/dist/vuetify.min.css"; diff --git a/aleksis/core/assets/components/app/SnackbarItem.vue b/aleksis/core/assets/components/app/SnackbarItem.vue index b60ef0e86841c158dd256222d77bc9181ed27210..95eeb13ab10367e5d7576dd22247e6c7c33993da 100644 --- a/aleksis/core/assets/components/app/SnackbarItem.vue +++ b/aleksis/core/assets/components/app/SnackbarItem.vue @@ -2,7 +2,7 @@ <v-snackbar :value="!snackbarItem.read" :color="snackbarItem.color"> {{ $t(snackbarItem.messageKey) }} <template #action="{ attrs }"> - <v-btn icon @click="checkSnackbarItem(snackbarItem.id)" + <v-btn icon @click="toggleSnackbarItem(snackbarItem.id)" ><v-icon>mdi-close</v-icon> </v-btn> </template> @@ -10,7 +10,7 @@ </template> <script> -import gqlCheckSnackbarItem from "./checkSnackbarItem.graphql"; +import gqlCheckSnackbarItem from "./toggleSnackbarItem.graphql"; export default { name: "SnackbarItem", @@ -21,7 +21,7 @@ export default { }, }, methods: { - checkSnackbarItem(id) { + toggleSnackbarItem(id) { this.$apollo.mutate({ mutation: gqlCheckSnackbarItem, variables: { id }, diff --git a/aleksis/core/assets/components/app/checkSnackbarItem.graphql b/aleksis/core/assets/components/app/checkSnackbarItem.graphql deleted file mode 100644 index a7a75a8c0783cffe974c7c5b8a240a0063535a23..0000000000000000000000000000000000000000 --- a/aleksis/core/assets/components/app/checkSnackbarItem.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation ($id: String!) { - checkSnackbarItem(id: $id) @client -} diff --git a/aleksis/core/assets/components/app/toggleSnackbarItem.graphql b/aleksis/core/assets/components/app/toggleSnackbarItem.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2badaf3185ce7ecb9c16b8ffd12ba5f6cff0f34b --- /dev/null +++ b/aleksis/core/assets/components/app/toggleSnackbarItem.graphql @@ -0,0 +1,3 @@ +mutation ($id: String!) { + toggleSnackbarItem(id: $id) @client +} diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js index 075b29235d9f2099297268cfc63f0a5c3df9f020..cfa1d88390339e9aeba0729d35555a02d9e6e268 100644 --- a/aleksis/core/assets/index.js +++ b/aleksis/core/assets/index.js @@ -1,3 +1,11 @@ +/* + * Main entrypoint of AlekSIS0-ore. + * + * This script sets up all necessary Vue plugins and defines the Vue app. + * For convenience, the Vue app instance will be made globally available + * as window.app. + */ + import Vue from "vue"; import Vuetify from "@/vuetify"; import VueI18n from "@/vue-i18n"; @@ -6,15 +14,18 @@ import VueApollo from "@/vue-apollo"; import AleksisVue from "./plugins/aleksis.js"; +// Install the AleksisVue plugin first and let it do early setup Vue.use(AleksisVue); Vue.$configureSentry(); Vue.$registerGlobalComponents(); +// Third-party plugins Vue.use(Vuetify); Vue.use(VueI18n); Vue.use(VueRouter); Vue.use(VueApollo); +// All of these imports yield config objects to be passed to the plugin constructors import vuetifyOpts from "./app/vuetify.js"; import i18nOpts from "./app/i18n.js"; import routerOpts from "./app/router.js"; @@ -25,6 +36,7 @@ const vuetify = new Vuetify(vuetifyOpts); const router = new VueRouter(routerOpts); const apolloProvider = new VueApollo(apolloOpts); +// Parent component rendering the UI and all features outside the specific pages import App from "./components/app/App.vue"; const app = new Vue({ @@ -41,6 +53,7 @@ const app = new Vue({ i18n, }); +// Late setup for some plugins handed off to out ALeksisVue plugin app.$loadAppMessages(); app.$setupNavigationGuards(); diff --git a/aleksis/core/assets/mixins/useRegisterSW.js b/aleksis/core/assets/mixins/useRegisterSW.js index 1034799fa5159c7a10d27457a39454202ccf84ba..e7659fca1f28c41f0ca1ee7feb4c6aeef458e525 100644 --- a/aleksis/core/assets/mixins/useRegisterSW.js +++ b/aleksis/core/assets/mixins/useRegisterSW.js @@ -1,4 +1,8 @@ -export default { +/** + * Vue mixin to register the PWA service worker once the main + * component gets ready. + */ +const useRegisterSWMixin = { name: "useRegisterSW", data() { return { @@ -55,3 +59,5 @@ export default { }, }, }; + +export default useRegisterSWMixin; diff --git a/aleksis/core/assets/plugins/aleksis.js b/aleksis/core/assets/plugins/aleksis.js index 2ae3085a38dba4d12294bca9e9069fe7188a0692..a1bed3845bfdd8fcd67900ef507277410a9503d2 100644 --- a/aleksis/core/assets/plugins/aleksis.js +++ b/aleksis/core/assets/plugins/aleksis.js @@ -1,14 +1,29 @@ +/* + * Plugin to collect AlekSIS-specific Vue utilities. + */ + +// aleksisAppImporter is a virtual module defined in Vite config import { appObjects, appMessages } from "aleksisAppImporter"; import MessageBox from "../components/MessageBox.vue"; -const AleksisVue = {}; - console.debug("Defining AleksisVue plugin"); +const AleksisVue = {}; AleksisVue.install = function (Vue, options) { + /** + * The browser title when the app was loaded. + * + * Thus, it is injected from Django in the vue_index template. + */ Vue.$pageBaseTitle = document.title; + /** + * Configure Sentry if desired. + * + * It depends on Sentry settings being passed as a DOM object by Django + * in the vue_index template. + */ Vue.$configureSentry = function () { if (document.getElementById("sentry_settings") !== null) { const Sentry = import("@sentry/vue"); @@ -32,9 +47,21 @@ AleksisVue.install = function (Vue, options) { } }; + /** + * Register all global components that shall be reusable by apps. + */ Vue.$registerGlobalComponents = function () { - Vue.component(MessageBox.name, MessageBox); + Vue.component("message-box", () => import("../components/MessageBox.vue")); }; + + /** + * Set the page title. + * + * This will automatically add the base title discovered at app loading time. + * + * @param {string} title Specific title to set, or null. + * @param {Object} route Route to discover title from, or null. + */ Vue.prototype.$setPageTitle = function (title, route) { let titleParts = []; @@ -55,6 +82,9 @@ AleksisVue.install = function (Vue, options) { document.title = newTitle; }; + /** + * Load i18n messages from all known AlekSIS apps. + */ Vue.prototype.$loadAppMessages = function () { console.log(this); for (const messages of Object.values(appMessages)) { @@ -64,9 +94,13 @@ AleksisVue.install = function (Vue, options) { } }; + /** + * Add navigation guards to account for global loading state and page titles. + */ Vue.prototype.$setupNavigationGuards = function () { // eslint-disable-next-line no-unused-vars this.$router.afterEach((to, from, next) => { + console.debug("Setting new page title due to route change"); window.app.$setPageTitle(null, to); }); @@ -80,13 +114,6 @@ AleksisVue.install = function (Vue, options) { this.$router.afterEach((to, from) => { window.app.contentLoading = false; }); - - // eslint-disable-next-line no-unused-vars - this.$router.beforeEach((to, from, next) => { - console.debug("Setting new page title due to route change"); - window.app.$setPageTitle(null, to); - next(); - }); }; }; diff --git a/aleksis/core/assets/routeValidators.js b/aleksis/core/assets/routeValidators.js index 5db52731038d43e9e941483dab8906de9a88763d..af9a2ad41c4c98e6c62c116227a4ba7c4fef7d05 100644 --- a/aleksis/core/assets/routeValidators.js +++ b/aleksis/core/assets/routeValidators.js @@ -1,3 +1,9 @@ +/** + * Check whether the user is logged in on the AlekSIS server. + * + * @param {Object} whoAmI The person object as returned by the whoAmI query + * @returns true if the user is logged in, false if not + */ const notLoggedInValidator = (whoAmI) => { return !whoAmI || whoAmI.isAnonymous; }; diff --git a/aleksis/core/assets/routes.js b/aleksis/core/assets/routes.js index 50a24935676d38982bf48ea14d05598ed50f054e..064e913ccb285d505210b96a8b15e432d0a88500 100644 --- a/aleksis/core/assets/routes.js +++ b/aleksis/core/assets/routes.js @@ -1,3 +1,11 @@ +/* + * Vue router definitions for all of AlekSIS. + * + * This module defines the routes of AlekSIS-Core and also loads + * and adds all routes from known apps. + */ + +// aleksisAppImporter is a virtual module defined in Vite config import { appObjects } from "aleksisAppImporter"; import { notLoggedInValidator } from "./routeValidators"; @@ -772,6 +780,7 @@ for (const [appName, appRoutes] of Object.entries(appObjects)) { }); } +// Fallback route defined last to ensure 404 view works routes.push({ path: "/*", component: () => import("./components/app/Error404.vue"),