Vue router - how to have multiple components loaded on the same route path based on user role?
Asked Answered
N

7

15

I have app where user can login in different roles, eg. seller, buyer and admin. For each user I'd like to show dashboard page on the same path, eg. http://localhost:8080/dashboard However, each user will have different dashboard defined in different vue components, eg. SellerDashboard, BuyerDashboard and AdminDashboard.

So basically, when user opens http://localhost:8080/dashboard vue app should load different component based on the user role (which I store in vuex). Similarly, I'd like to have this for other routes. For example, when user goes to profile page http://localhost:8080/profile app should show different profile component depending on the logged in user.

So I'd like to have the same route for all users roles as opposed to have different route for each user role, eg. I don't want user role to be contained in url like following: http://localhost:8080/admin/profile and http://localhost:8080/seller/profile etc...

How can I implement this scenario with vue router?

I tried using combination of children routes and per-route guard beforeEnter to resolve to a route based on user role. Here is a code sample of that:

in router.js:

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import store from '@/store'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: Home,

    beforeEnter: (to, from, next) => {
      next({ name: store.state.userRole })
    },

    children: [
      {
        path: '',
        name: 'admin',
        component: () => import('@/components/Admin/AdminDashboard')
      },
      {
        path: '',
        name: 'seller',
        component: () => import('@/components/Seller/SellerDashboard')
      },
      {
        path: '',
        name: 'buyer',
        component: () => import('@/components/Buyer/BuyerDashboard')
      }
    ]
  },
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

in store.js:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    userRole: 'seller' // can also be 'buyer' or 'admin'
  }
})

App.vue contains parent router-view for top-level routes, eg. map / to Home component and /about to About component:

<template>
  <router-view/>
</template>

<script>
export default {
  name: 'App',
}
</script>

And Home.vue contains nested router-view for different user's role-based components:

<template>
  <div class="home fill-height" style="background: #ddd;">
    <h1>Home.vue</h1>
    <!-- nested router-view where user specific component should be rendered -->
    <router-view style="background: #eee" />
  </div>
</template>

<script>
export default {
  name: 'home'
}
</script>

But it doesn't work because I get Maximum call stack size exceeded exception in browser console when I call next({ name: store.state.userRole }) in beforeEnter. The exception is:

vue-router.esm.js?8c4f:2079 RangeError: Maximum call stack size exceeded
    at VueRouter.match (vue-router.esm.js?8c4f:2689)
    at HTML5History.transitionTo (vue-router.esm.js?8c4f:2033)
    at HTML5History.push (vue-router.esm.js?8c4f:2365)
    at eval (vue-router.esm.js?8c4f:2135)
    at beforeEnter (index.js?a18c:41)
    at iterator (vue-router.esm.js?8c4f:2120)
    at step (vue-router.esm.js?8c4f:1846)
    at runQueue (vue-router.esm.js?8c4f:1854)
    at HTML5History.confirmTransition (vue-router.esm.js?8c4f:2147)
    at HTML5History.transitionTo (vue-router.esm.js?8c4f:2034)

and thus nothing is rendered.

Is there a way I can solve this?

Nissie answered 16/12, 2019 at 16:22 Comment(0)
E
6

You might want to try something around this solution:

<template>
  <component :is="compName">
</template>
data: () {
 return {
      role: 'seller' //insert role here - maybe on `created()` or wherever
 }
},
components: {
 seller: () => import('/components/seller'),
 admin: () => import('/components/admin'),
 buyer: () => import('/components/buyer'),
}

Or if you prefer maybe a bit more neat (same result) :

<template>
  <component :is="loadComp">
</template>
data: () => ({compName: 'seller'}),
computed: {
 loadComp () {
  const compName = this.compName
  return () => import(`/components/${compName}`)
 }
}

This will give you the use of dynamic components without having to import all of the cmps up front, but using only the one needed every time.

Enthrone answered 19/12, 2019 at 10:46 Comment(1)
Where does compName in your first example come from?Sculpin
R
5

One approach would be to use a dynamic component. You could have a single child route whose component is also non-specific (e.g. DashboardComponent):

router.js

const routes = [
  {
    path: '/',
    name: 'home',
    children: [
      {
        path: '',
        name: 'dashboard',
        component: () => import('@/components/Dashboard')
      }
    ]
  }
]

components/Dashboard.vue

<template>
  <!-- wherever your component goes in the layout -->
  <component :is="dashboardComponent"></component>
</template>

<script>
import AdminDashboard from '@/components/Admin/AdminDashboard'
import SellerDashboard from '@/components/Seller/SellerDashboard'
import BuyerDashboard from '@/components/Buyer/BuyerDashboard'

const RoleDashboardMapping = {
  admin: AdminDashboard,
  seller: SellerDashboard,
  buyer: BuyerDashboard
}

export default {
  data () {
    return {
      dashboardComponent: RoleDashboardMapping[this.$store.state.userRole]
    }
  }
}
</script>
Robet answered 16/12, 2019 at 16:38 Comment(6)
Thanks for the answer but it might not be what I need. Ideally I'd like to use webpack import() to dynamically fetch just the chunks of the application required for the current user role. Eg. if user is logged in as admin I don't want to load components for other roles. In your case I would statically import all components in Dashboard.vue and thus deliver them to all users no matter the role. The second thing is that I'd like to have this logic contained in router rather than spread across multiple generic components that are used only as containers for their role-specific versions.Nissie
What about a single child route and setting name to store.state.userRole, then dynamically building the import path based on that value as well?Robet
Dynamically building import() path won't work because import() works only with static strings defined upfront at build time. This is required for webpack to know what js files should be bundled etc... But I tried that just in case now and do confirm that as I get runtime exception when I try to load route component dynamically vue-router.esm.js?8c4f:2079 Error: Cannot find module '@/components/Seller/SellerProfile' :(Nissie
Ah, gotcha. Sorry my answer isn't what you were looking for, it was just my first thought. I'm relatively new to Vue (but have some experience in other front end frameworks).Robet
@Nissie out of curiosity (I can't test it at the moment) what if you add a check in beforeEnter such as if (to.name !== store.state.userRole) { next({ name: store.state.userRole }) } ... and then an else { next() }?Robet
Just to clarify - Webpack import() in fact supports "partially dynamic" expressions - Webpack 5 and Webpack 4Casarez
T
5

Such code retrieves component code only for a given role:

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";
import store from "../store";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "home",
    component: () => {
      switch (store.state.userRole) {
        case "admin":
          return import("../components/AdminDashboard");
        case "buyer":
          return import("../components/BuyerDashboard");
        case "seller":
          return import("../components/SellerDashboard");
        default:
          return Home;
      }
    }
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

export default router;
Tepid answered 25/12, 2019 at 13:45 Comment(1)
Just be warned that this method will not work if your app supports logout/login without full page refresh. Because Router will call that component function only once and then cache and reuse the component resolved from the Promise returned by the importCasarez
L
2

Vue Router 4 (Vue 3)

If you are using Vue Router 4 (usable only with Vue 3), one alternative solution is to use dynamic routing

This new feature allows us to remove/add routes on the fly.

// router.js
import { createRouter, createWebHistory } from 'vue-router'
import store from "../store";
import Home from "../views/Home.vue";
import About from "../views/About.vue";

// all routes independent of user role
const staticRoutes = [
  {
    path: "/",
    name: "home",
    component: Home,
  },
  {
    path: "/about",
    name: "about",
    component: About,
  },
]

const getRoutesForRole = (role) => {
  // imlementation can vary - see the rest of the answer
}

// routes used at app initialization
const initialRoutes = [...staticRoutes, ...getRoutesForRole(store.state.userRole)]

const router = createRouter({
  history: createWebHistory(),
  routes: initialRoutes,
})

export default router

export const updateRoutesForRole = () {
  // implementation can vary - see the rest of the answer
}

How to generate dynamic routes - getRoutesForRole

The implementation of course depends on many factors - how many routes (and also roles) do you have is probably most important.

With just 2 or 3 routes (and not many roles) it is just fine to use a static definition:

const routesPerRole = {
    "admin": [
      {
        path: "/dashboard",
        name: "dashboard",
        component: () => import("../components/AdminDashboard.vue")
      }, // more routes follow....
    ],
    "seller": [
      {
        path: "/dashboard",
        name: "dashboard",
        component: () => import("../components/SellerDashboard.vue")
      }, // more routes follow....
    ],
    "buyer": [
      {
        path: "/dashboard",
        name: "dashboard",
        component: () => import("../components/BuyerDashboard.vue")
      }, // more routes follow....
    ],
  }

const getRoutesForRole = (role) => {
  if(!role) return []

  return routesPerRole[role]  
}

If you have many routes and/or many roles, you probably want something more generic. First we need some good naming convention - for example lets say that we will organize our components in a directory structure like this: @/components/${role}/${componentName}.vue

Then we can use Webpacks dynamic import

const routeTemplates = [
  {
    path: "/dashboard",
    name: "dashboard",
    component: 'Dashboard'
  },
]

const getRoutesForRole = (role) => {
  if(!role) return []

  const routesForRole = routeTemplates.map(route => ({
    ...route,
    component: () => import(`@/components/${role}/${route.component}.vue`)
  }))
  
  return routesForRole 
}

Note that thanks to how import() with dynamic expression works in Webpack this will make Webpack to create new JS chunk for each component in @/components folder which may be not what you want.

Easy fix is to move the "role dependent" components into it's own subfolder so instead of using @/components/admin/.... just use @/components/perRoleComponents/admin/.... and

import(`@/components/perRoleComponents/${role}/${route.component}.vue`)

Other solution is to use different import() statement for each role. This will also allow us to use Webpacks "magic comments" and for example force Webpack to pack all components for each role into single js chunk:

const routeTemplates = [
  {
    path: "/dashboard",
    name: "dashboard",
    component: 'Dashboard'
  },
]

const getComponentLoader = (role, componentName) => {
  switch(role) {
    "admin": return () => import(
       /* webpackChunkName: "admin-components" */
       /* webpackMode: "lazy-once" */
       `@/components/admin/${componentName}.vue`)

    "seller": return () => import(
       /* webpackChunkName: "seller-components" */
       /* webpackMode: "lazy-once" */
       `@/components/seller/${componentName}.vue`)

    "buyer": return () => import(
       /* webpackChunkName: "buyer-components" */
       /* webpackMode: "lazy-once" */
       `@/components/buyer/${componentName}.vue`)
  }
}

const getRoutesForRole = (role) => {
  if(!role) return []

  const routesForRole = routeTemplates.map(route => ({
    ...route,
    component: getComponentLoader(role, route.component)
  }))
  
  return routesForRole 
}

How to update routes - updateRoutesForRole()

Easiest scenario is when each role has same set of routes and just wants to use a different component. In this case to switch the routes when role changes we can just use addRoute

Add a new route record to the router. If the route has a name and there is already an existing one with the same one, it removes it first.

export const updateRoutesForRole = () {
  const role = store.state.userRole
  const routesForRole = getRoutesForRole(role)
 
  routesForRole.forEach(r => router.addRoute(r))
}

For more complicated scenarios where not all routes are available for all roles, previous routes (for previous active role - if any) must be removed 1st using removeRoute function. Also our getRoutesForRole() must be different. One solution is to use route meta fields

const routeTemplates = [
  {
    path: "/dashboard",
    name: "dashboard",
    component: 'Dashboard',
    meta: { forRoles: ['admin', 'seller'] }
  },
]

const getRoutesForRole = (role) => {
  if(!role) return []

  const routesForRole = routeTemplates
  .filter(route => route.meta?.forRoles?.includes(role))
  .map(route => ({
    ...route,
    component: () => import(`@/components/${role}/${route.component}.vue`)
  }))
  
  return routesForRole 
}

export const updateRoutesForRole = () {
  const role = store.state.userRole
 
  // delete previous 1st
  router.getRoutes()
    .filter(route => route.meta?.forRoles)
    .forEach(route => router.removeRoute(route.name))

  const routesForRole = getRoutesForRole(role)
 
  routesForRole.forEach(r => router.addRoute(r))
}

Router v3 (for Vue 2)

Note that Router v3 (and earlier) was never designed with dynamic routing in mind. There is no removeRoute() function. There is a addRoute() so some of the scenarios described above could be probably possible but it currently (Router v3.5.3) does not work as described in the documentation

Legal answered 10/1, 2022 at 15:47 Comment(0)
S
1

You run into the Maximum call stack size exceeded exception because the next({ name: store.state.userRole }) will trigger another redirection and call the beforeEnter again and thus results in infinite loop. To solve this, you can check on the to param, and if it is already set, you can call next() to confirm the navigation, and it will not cause re-direction. See code below:

beforeEnter: (to, from, next) => {
  // Helper to inspect the params.
  console.log("to", to, "from", from)

  // this is just an example, in your case, you may need
  // to verify the value of `to.name` is not 'home' etc. 
  if (to.name) { 
    next();
  } else {
    next({ name: store.state.userRole })
  }
},
Shiv answered 19/12, 2019 at 19:45 Comment(0)
K
1

I faced the same problem (I use Meteor JS with Vue JS) and I found the way to do it with the render function to load different components on the same route. So, in your case it should be:

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";
import AdminDashboard from "../components/AdminDashboard";
import BuyerDashboard from "../components/BuyerDashboard";
import SellerDashboard from "../components/SellerDashboard";
import store from "../store";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "home",
    component: {
      render: (h) => {
          switch (store.state.userRole) {
             case "admin":
               return h(AdminDashboard);
             case "buyer":
               return h(BuyerDashboard);
             case "seller":
               return h(SellerDashboard);
             default:
               return h(Home);
           }
       }
    }
   }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

export default router;

Note that this solution also works but only for the first time, if you enter again to that route, the last component loaded it will keep (you will need to reload the page). So, with the render function it always load the new component.

Krystinakrystle answered 15/6, 2020 at 22:56 Comment(0)
H
-1

One way to solve this is to create three separate components DashboardForAdmin, DashBoardForSeller, and DashBoardForBuyer for three types of users.

Then use a mixin.js

export default {
    data: function () {
        return {
          userType : "buyer"; // replace this with a function that returns "seller", "buyer", or "admin"
        }
    }
}

Create a Vue component DashboardContainer renders the correct dashboard component based on mixin return value

    <template>
        <div>
            <div v-if="userType === 'admin'">
                <DashboardForAdmin />
            </div>
            <div v-else-if="userType === 'buyer'">
                <DashboardForBuyer />
            </div>
            <div v-else>
                <DashboardForSeller />
            </div>
        </div>
    </template>

    <script>
        import mixin from '@/mixin.js';

        import DashboardForAdmin from '@/components/DashboardForAdmin.vue';
        import DashBoardForSeller from '@/components/DashBoardForSeller.vue';
        import DashBoardForBuyer from '@/components/DashBoardForBuyer.vue';

        export default {
            mixins: [mixin],
            components: {
                DashboardForAdmin, DashBoardForSeller, DashBoardForBuyer 
            },
        };
    </script>

Now you can add a single route for the DashboardContainer

Hortense answered 19/12, 2019 at 20:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.