diff --git a/aleksis/core/frontend/components/app/AccountMenu.vue b/aleksis/core/frontend/components/app/AccountMenu.vue index 70e8c52fa770b743c138c6212f296b223be83340..ed5ee1ba5873756b37be18e21cea7c0603f1b8e2 100644 --- a/aleksis/core/frontend/components/app/AccountMenu.vue +++ b/aleksis/core/frontend/components/app/AccountMenu.vue @@ -52,7 +52,11 @@ <v-list-item-icon> <v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon> </v-list-item-icon> - <v-list-item-title>{{ $t(menuItem.titleKey) }}</v-list-item-title> + <v-list-item-title>{{ + !menuItem.rawTitleString + ? $t(menuItem.titleKey) + : menuItemm.rawTitleString + }}</v-list-item-title> </v-list-item> </div> </v-list> diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index 8896f20785f8992c81e5cecf4488536b9d29caf8..e11725a36e9dc33310750397a68305b3af05c566 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -82,8 +82,17 @@ </message-box> </div> + <error-page + v-if="error404" + short-error-message-key="network_errors.error_404" + long-error-message-key="network_errors.page_not_found" + redirect-button-text-key="network_errors.back_to_start" + redirect-route-name="dashboard" + redirect-button-icon="mdi-home-outline" + > + </error-page> <router-view - v-if=" + v-else-if=" !$route.meta.permission || checkPermission($route.meta.permission) || $route.name === 'dashboard' @@ -216,6 +225,7 @@ import gqlSystemProperties from "./systemProperties.graphql"; import useRegisterSWMixin from "../../mixins/useRegisterSW"; import offlineMixin from "../../mixins/offline"; import menusMixin from "../../mixins/menus"; +import routesMixin from "../../mixins/routes"; export default { data() { @@ -224,6 +234,7 @@ export default { whoAmI: null, systemProperties: null, messages: null, + error404: false, }; }, apollo: { @@ -259,6 +270,16 @@ export default { }, deep: true, }, + $route: { + handler(newRoute) { + if (newRoute.matched.length == 0) { + this.error404 = true; + } else { + this.error404 = false; + } + }, + immediate: true, + }, }, name: "App", components: { @@ -270,7 +291,7 @@ export default { SideNav, SnackbarItem, }, - mixins: [useRegisterSWMixin, offlineMixin, menusMixin], + mixins: [useRegisterSWMixin, offlineMixin, menusMixin, routesMixin], }; </script> diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue index 972b24eb79e9200cc7d5b16638ee37a640a8b458..0975c580cda88d03fd8a5d34b2fb16425ece69ee 100644 --- a/aleksis/core/frontend/components/app/SideNav.vue +++ b/aleksis/core/frontend/components/app/SideNav.vue @@ -19,11 +19,19 @@ v-if="menuItem.subMenu.length > 0" href="#!" :prepend-icon="menuItem.icon" - :value="$route.matched.slice(-2).shift().name === menuItem.name" + :value=" + $route.matched.slice(-2).shift() + ? $route.matched.slice(-2).shift().name === menuItem.name + : false + " > <template #activator> <v-list-item-title - >{{ $t(menuItem.titleKey) }} + >{{ + !menuItem.rawTitleString + ? $t(menuItem.titleKey) + : menuItem.rawTitleString + }} </v-list-item-title> </template> <v-list-item @@ -38,7 +46,11 @@ <v-icon v-if="subMenuItem.icon">{{ subMenuItem.icon }} </v-icon> </v-list-item-icon> <v-list-item-title - >{{ $t(subMenuItem.titleKey) }} + >{{ + !subMenuItem.rawTitleString + ? $t(subMenuItem.titleKey) + : subMenuItem.rawTitleString + }} </v-list-item-title> </v-list-item> </v-list-group> @@ -52,7 +64,11 @@ <v-list-item-icon> <v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon> </v-list-item-icon> - <v-list-item-title>{{ $t(menuItem.titleKey) }}</v-list-item-title> + <v-list-item-title>{{ + !menuItem.rawTitleString + ? $t(menuItem.titleKey) + : menuItem.rawTitleString + }}</v-list-item-title> </v-list-item> </div> </v-list-item-group> diff --git a/aleksis/core/frontend/components/app/dynamicRoutes.graphql b/aleksis/core/frontend/components/app/dynamicRoutes.graphql new file mode 100644 index 0000000000000000000000000000000000000000..49c208b729f4185b0053e8bfbff13fd3ca8c78f5 --- /dev/null +++ b/aleksis/core/frontend/components/app/dynamicRoutes.graphql @@ -0,0 +1,18 @@ +{ + dynamicRoutes { + parentRouteName + + routePath + routeName + + displayAccountMenu + displaySidenavMenu + menuNewTab + + menuTitle + menuIcon + + menuPermission + routePermission + } +} diff --git a/aleksis/core/frontend/mixins/routes.js b/aleksis/core/frontend/mixins/routes.js new file mode 100644 index 0000000000000000000000000000000000000000..813edd5777e9868680d5a5d9e8e495d70ef35790 --- /dev/null +++ b/aleksis/core/frontend/mixins/routes.js @@ -0,0 +1,60 @@ +import gqlDynamicRoutes from "../components/app/dynamicRoutes.graphql"; + +/** + * Vue mixin containing code getting dynamically added routes from other apps. + * + * Only used by main App component, but factored out for readability. + */ +const routesMixin = { + data() { + return { + dynamicRoutes: null, + }; + }, + apollo: { + dynamicRoutes: { + query: gqlDynamicRoutes, + pollInterval: 60000, + }, + }, + watch: { + dynamicRoutes: { + handler(newDynamicRoutes) { + for (const route of newDynamicRoutes) { + if (route) { + console.debug("Adding new dynamic route:", route.routeName); + let routeEntry = { + path: route.routePath, + name: route.routeName, + component: () => import("../components/LegacyBaseTemplate.vue"), + props: { + byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, + }, + meta: { + inMenu: route.displaySidenavMenu, + inAccountMenu: route.displayAccountMenu, + icon: route.menuIcon, + rawTitleString: route.menuTitle, + menuPermission: route.menuPermission, + permission: route.routePermission, + newTab: route.menuNewTab, + }, + }; + + if (route.parentRouteName) { + this.$router.addRoute(route.parentRouteName, routeEntry); + } else { + this.$router.addRoute(routeEntry); + } + } + } + + this.getPermissionNames(); + this.buildMenus(); + }, + deep: true, + }, + }, +}; + +export default routesMixin; diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 225bbef90d58f5428e419c84433c6956d39057c9..9d4a03d40d224ab5de3e6d1f4d4ca27e3661b114 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -1064,18 +1064,4 @@ for (const [appName, appRoutes] of Object.entries(appObjects)) { }); } -// Fallback route defined last to ensure 404 view works -routes.push({ - path: "/*", - component: () => import("./components/app/ErrorPage.vue"), - name: "core.error404", - props: { - shortErrorMessageKey: "network_errors.error_404", - longErrorMessageKey: "network_errors.page_not_found", - redirectButtonTextKey: "network_errors.back_to_start", - redirectRouteName: "dashboard", - redirectButtonIcon: "mdi-home-outline", - }, -}); - export default routes; diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 04d7703ca42a6f87ea2fcd41487d9e173022d2cb..1a370c2c68af1569f92a9cc09ada7f8b5f671649 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1093,6 +1093,14 @@ class CustomMenuItem(ExtensibleModel): return reverse("admin:core_custommenuitem_change", args=[self.id]) +class DynamicRoute(RegistryObject): + """Define a dynamic route. + + Dynamic routes should be used to register Vue routes dynamically, e. g. + when an app is supposed to show menu items for dynamically creatable objects. + """ + + class GroupType(ExtensibleModel): """Group type model. diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index 70a2b23910a06039aea1f88d753cd205cbe75f6d..6b442beab63e67ee8f09006f6b325a0917d3cdab 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -9,11 +9,12 @@ from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex -from ..models import CustomMenu, Notification, PDFFile, Person, TaskUserAssignment +from ..models import CustomMenu, DynamicRoute, Notification, PDFFile, Person, TaskUserAssignment from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType +from .dynamic_routes import DynamicRouteType from .group import GroupType # noqa from .installed_apps import AppType from .message import MessageType @@ -53,6 +54,8 @@ class Query(graphene.ObjectType): custom_menu_by_name = graphene.Field(CustomMenuType, name=graphene.String()) + dynamic_routes = graphene.List(DynamicRouteType) + def resolve_ping(root, info, payload) -> str: return payload @@ -136,6 +139,14 @@ class Query(graphene.ObjectType): def resolve_custom_menu_by_name(root, info, name, **kwargs): return CustomMenu.get_default(name) + def resolve_dynamic_routes(root, info, **kwargs): + dynamic_routes = [] + + for dynamic_route_object in DynamicRoute.registered_objects_dict.values(): + dynamic_routes += dynamic_route_object.get_dynamic_routes() + + return dynamic_routes + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() diff --git a/aleksis/core/schema/dynamic_routes.py b/aleksis/core/schema/dynamic_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..5c2fd6336ddb05220447e5ac7df58bbbf98ef74f --- /dev/null +++ b/aleksis/core/schema/dynamic_routes.py @@ -0,0 +1,18 @@ +import graphene + + +class DynamicRouteType(graphene.ObjectType): + parent_route_name = graphene.String() + + route_path = graphene.String() + route_name = graphene.String() + + display_account_menu = graphene.Boolean() + display_sidenav_menu = graphene.Boolean() + menu_new_tab = graphene.Boolean() + + menu_title = graphene.String() + menu_icon = graphene.String() + + menu_permission = graphene.String() + route_permission = graphene.String()