This is an automated email from the ASF dual-hosted git repository. wusheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-booster-ui.git
The following commit(s) were added to refs/heads/main by this push: new a7972af3 refactor: optimize the router system and implement unit tests for router (#495) a7972af3 is described below commit a7972af3b43649a3770c43be92a0350298e288ec Author: Fine0830 <fanxue0...@gmail.com> AuthorDate: Thu Aug 28 21:11:46 2025 +0800 refactor: optimize the router system and implement unit tests for router (#495) --- package.json | 1 + src/App.vue | 2 +- src/__tests__/App.spec.ts | 12 +- src/layout/components/SideBar.vue | 2 +- src/router/__tests__/constants.spec.ts | 231 +++++++++++++ src/router/__tests__/guards.spec.ts | 253 ++++++++++++++ src/router/__tests__/index.spec.ts | 263 +++++++++++++++ src/router/__tests__/route-modules.spec.ts | 518 +++++++++++++++++++++++++++++ src/router/__tests__/utils.spec.ts | 465 ++++++++++++++++++++++++++ src/router/alarm.ts | 24 +- src/router/constants.ts | 60 ++++ src/router/dashboard.ts | 203 +++++++---- src/router/guards.ts | 98 ++++++ src/router/index.ts | 43 +-- src/router/layer.ts | 109 +++--- src/router/marketplace.ts | 24 +- src/router/notFound.ts | 13 +- src/router/settings.ts | 24 +- src/router/utils.ts | 102 ++++++ src/{router/alarm.ts => types/router.ts} | 55 +-- 20 files changed, 2309 insertions(+), 193 deletions(-) diff --git a/package.json b/package.json index 86d91236..6568018e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:hooks": "vitest --environment jsdom src/hooks/**/*.spec.ts", "test:stores": "vitest --environment jsdom src/store/**/*.spec.ts", "test:views": "vitest --environment jsdom src/views/**/*.spec.ts", + "test:router": "vitest --environment jsdom src/router/**/*.spec.ts", "test:all": "vitest --environment jsdom --root src/ --coverage --reporter=verbose", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'" }, diff --git a/src/App.vue b/src/App.vue index 19b8887f..fc5b1226 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,7 +20,7 @@ limitations under the License. --> const route = useRoute(); setTimeout(() => { - if (route.name === "ViewWidget") { + if (route.name === "DashboardViewWidget") { (document.querySelector("#app") as any).style.minWidth = "120px"; } else { (document.querySelector("#app") as any).style.minWidth = "1024px"; diff --git a/src/__tests__/App.spec.ts b/src/__tests__/App.spec.ts index f1a083c2..a4824c08 100644 --- a/src/__tests__/App.spec.ts +++ b/src/__tests__/App.spec.ts @@ -62,8 +62,8 @@ describe("App Component", () => { expect(wrapper.find("router-view").exists()).toBe(true); }); - it("should set minWidth to 120px for ViewWidget route", async () => { - mockRoute.name = "ViewWidget"; + it("should set minWidth to 120px for DashboardViewWidget route", async () => { + mockRoute.name = "DashboardViewWidget"; const wrapper = mount(App); @@ -77,7 +77,7 @@ describe("App Component", () => { } }); - it("should set minWidth to 1024px for non-ViewWidget routes", async () => { + it("should set minWidth to 1024px for non-DashboardViewWidget routes", async () => { mockRoute.name = "Dashboard"; const wrapper = mount(App); @@ -121,7 +121,7 @@ describe("App Component", () => { // Unmount and remount with different route wrapper.unmount(); - mockRoute.name = "ViewWidget"; + mockRoute.name = "DashboardViewWidget"; vi.mocked(useRoute).mockReturnValue(mockRoute); const wrapper2 = mount(App); @@ -136,7 +136,7 @@ describe("App Component", () => { it("should handle multiple route changes", async () => { // Test multiple route changes by remounting - const routes = ["Home", "ViewWidget", "Dashboard", "ViewWidget"]; + const routes = ["Home", "DashboardViewWidget", "Dashboard", "DashboardViewWidget"]; let wrapper: any = null; for (const routeName of routes) { @@ -153,7 +153,7 @@ describe("App Component", () => { const appElement = document.querySelector("#app"); if (appElement) { - const expectedWidth = routeName === "ViewWidget" ? "120px" : "1024px"; + const expectedWidth = routeName === "DashboardViewWidget" ? "120px" : "1024px"; expect((appElement as HTMLElement).style.minWidth).toBe(expectedWidth); } } diff --git a/src/layout/components/SideBar.vue b/src/layout/components/SideBar.vue index 8f6f3ab5..955f847f 100644 --- a/src/layout/components/SideBar.vue +++ b/src/layout/components/SideBar.vue @@ -96,7 +96,7 @@ limitations under the License. --> } else { appStore.setIsMobile(false); } - if (route.name === "ViewWidget") { + if (route.name === "DashboardViewWidget") { showMenu.value = false; } diff --git a/src/router/__tests__/constants.spec.ts b/src/router/__tests__/constants.spec.ts new file mode 100644 index 00000000..0cc4165d --- /dev/null +++ b/src/router/__tests__/constants.spec.ts @@ -0,0 +1,231 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS, DEFAULT_ROUTE } from "../constants"; + +describe("Router Constants", () => { + describe("ROUTE_NAMES", () => { + it("should define all required route names", () => { + expect(ROUTE_NAMES).toHaveProperty("MARKETPLACE"); + expect(ROUTE_NAMES).toHaveProperty("DASHBOARD"); + expect(ROUTE_NAMES).toHaveProperty("ALARM"); + expect(ROUTE_NAMES).toHaveProperty("SETTINGS"); + expect(ROUTE_NAMES).toHaveProperty("NOT_FOUND"); + expect(ROUTE_NAMES).toHaveProperty("LAYER"); + }); + + it("should have correct route name values", () => { + expect(ROUTE_NAMES.MARKETPLACE).toBe("Marketplace"); + expect(ROUTE_NAMES.DASHBOARD).toBe("Dashboard"); + expect(ROUTE_NAMES.ALARM).toBe("Alarm"); + expect(ROUTE_NAMES.SETTINGS).toBe("Settings"); + expect(ROUTE_NAMES.NOT_FOUND).toBe("NotFound"); + expect(ROUTE_NAMES.LAYER).toBe("Layer"); + }); + + it("should be defined as constants", () => { + // Note: Constants are not actually frozen in the implementation + // but they should be treated as constants by convention + expect(ROUTE_NAMES).toBeDefined(); + expect(typeof ROUTE_NAMES).toBe("object"); + }); + }); + + describe("ROUTE_PATHS", () => { + it("should define root path", () => { + expect(ROUTE_PATHS).toHaveProperty("ROOT"); + expect(ROUTE_PATHS.ROOT).toBe("/"); + }); + + it("should define marketplace path", () => { + expect(ROUTE_PATHS).toHaveProperty("MARKETPLACE"); + expect(ROUTE_PATHS.MARKETPLACE).toBe("/marketplace"); + }); + + it("should define dashboard paths", () => { + expect(ROUTE_PATHS).toHaveProperty("DASHBOARD"); + expect(ROUTE_PATHS.DASHBOARD).toHaveProperty("LIST"); + expect(ROUTE_PATHS.DASHBOARD).toHaveProperty("NEW"); + expect(ROUTE_PATHS.DASHBOARD).toHaveProperty("EDIT"); + expect(ROUTE_PATHS.DASHBOARD).toHaveProperty("VIEW"); + expect(ROUTE_PATHS.DASHBOARD).toHaveProperty("WIDGET"); + }); + + it("should have correct dashboard path values", () => { + expect(ROUTE_PATHS.DASHBOARD.LIST).toBe("/dashboard/list"); + expect(ROUTE_PATHS.DASHBOARD.NEW).toBe("/dashboard/new"); + expect(ROUTE_PATHS.DASHBOARD.EDIT).toBe("/dashboard/:layerId/:entity/:name"); + expect(ROUTE_PATHS.DASHBOARD.VIEW).toBe("/dashboard/:layerId/:entity/:serviceId/:name"); + expect(ROUTE_PATHS.DASHBOARD.WIDGET).toBe( + "/page/:layer/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:config/:duration?", + ); + }); + + it("should define alarm path", () => { + expect(ROUTE_PATHS).toHaveProperty("ALARM"); + expect(ROUTE_PATHS.ALARM).toBe("/alerting"); + }); + + it("should define settings path", () => { + expect(ROUTE_PATHS).toHaveProperty("SETTINGS"); + expect(ROUTE_PATHS.SETTINGS).toBe("/settings"); + }); + + it("should define not found path", () => { + expect(ROUTE_PATHS).toHaveProperty("NOT_FOUND"); + expect(ROUTE_PATHS.NOT_FOUND).toBe("/:pathMatch(.*)*"); + }); + + it("should be defined as constants", () => { + // Note: Constants are not actually frozen in the implementation + // but they should be treated as constants by convention + expect(ROUTE_PATHS).toBeDefined(); + expect(typeof ROUTE_PATHS).toBe("object"); + expect(ROUTE_PATHS.DASHBOARD).toBeDefined(); + }); + }); + + describe("META_KEYS", () => { + it("should define all required meta keys", () => { + expect(META_KEYS).toHaveProperty("I18N_KEY"); + expect(META_KEYS).toHaveProperty("ICON"); + expect(META_KEYS).toHaveProperty("HAS_GROUP"); + expect(META_KEYS).toHaveProperty("ACTIVATE"); + expect(META_KEYS).toHaveProperty("TITLE"); + expect(META_KEYS).toHaveProperty("DESC_KEY"); + expect(META_KEYS).toHaveProperty("LAYER"); + expect(META_KEYS).toHaveProperty("NOT_SHOW"); + expect(META_KEYS).toHaveProperty("REQUIRES_AUTH"); + expect(META_KEYS).toHaveProperty("BREADCRUMB"); + }); + + it("should have correct meta key values", () => { + expect(META_KEYS.I18N_KEY).toBe("i18nKey"); + expect(META_KEYS.ICON).toBe("icon"); + expect(META_KEYS.HAS_GROUP).toBe("hasGroup"); + expect(META_KEYS.ACTIVATE).toBe("activate"); + expect(META_KEYS.TITLE).toBe("title"); + expect(META_KEYS.DESC_KEY).toBe("descKey"); + expect(META_KEYS.LAYER).toBe("layer"); + expect(META_KEYS.NOT_SHOW).toBe("notShow"); + expect(META_KEYS.REQUIRES_AUTH).toBe("requiresAuth"); + expect(META_KEYS.BREADCRUMB).toBe("breadcrumb"); + }); + + it("should be defined as constants", () => { + // Note: Constants are not actually frozen in the implementation + // but they should be treated as constants by convention + expect(META_KEYS).toBeDefined(); + expect(typeof META_KEYS).toBe("object"); + }); + }); + + describe("DEFAULT_ROUTE", () => { + it("should be defined", () => { + expect(DEFAULT_ROUTE).toBeDefined(); + }); + + it("should match marketplace path", () => { + expect(DEFAULT_ROUTE).toBe(ROUTE_PATHS.MARKETPLACE); + }); + + it("should be defined as a constant", () => { + // Note: Constants are not actually frozen in the implementation + // but they should be treated as constants by convention + expect(DEFAULT_ROUTE).toBeDefined(); + expect(typeof DEFAULT_ROUTE).toBe("string"); + }); + }); + + describe("Constants Integration", () => { + it("should have consistent route names and paths", () => { + // Check that route names correspond to actual route paths + expect(ROUTE_NAMES.MARKETPLACE).toBe("Marketplace"); + expect(ROUTE_PATHS.MARKETPLACE).toBe("/marketplace"); + + expect(ROUTE_NAMES.DASHBOARD).toBe("Dashboard"); + expect(ROUTE_PATHS.DASHBOARD.LIST).toBe("/dashboard/list"); + + expect(ROUTE_NAMES.ALARM).toBe("Alarm"); + expect(ROUTE_PATHS.ALARM).toBe("/alerting"); + + expect(ROUTE_NAMES.SETTINGS).toBe("Settings"); + expect(ROUTE_PATHS.SETTINGS).toBe("/settings"); + }); + + it("should have valid path patterns", () => { + // Check that parameterized paths have valid syntax + expect(ROUTE_PATHS.DASHBOARD.EDIT).toMatch(/^\/dashboard\/:[^/]+\/:[^/]+\/:[^/]+$/); + expect(ROUTE_PATHS.DASHBOARD.VIEW).toMatch(/^\/dashboard\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+$/); + expect(ROUTE_PATHS.DASHBOARD.WIDGET).toMatch( + /^\/page\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/:[^/]+\/?$/, + ); + }); + + it("should have consistent meta key usage", () => { + // Check that meta keys are used consistently across route definitions + const expectedMetaKeys = Object.values(META_KEYS); + expect(expectedMetaKeys).toContain("i18nKey"); + expect(expectedMetaKeys).toContain("icon"); + expect(expectedMetaKeys).toContain("hasGroup"); + expect(expectedMetaKeys).toContain("activate"); + expect(expectedMetaKeys).toContain("title"); + expect(expectedMetaKeys).toContain("breadcrumb"); + }); + }); + + describe("Type Safety", () => { + it("should have consistent string types", () => { + // All route names should be strings + Object.values(ROUTE_NAMES).forEach((value) => { + expect(typeof value).toBe("string"); + }); + + // All meta keys should be strings + Object.values(META_KEYS).forEach((value) => { + expect(typeof value).toBe("string"); + }); + + // Root path should be string + expect(typeof ROUTE_PATHS.ROOT).toBe("string"); + + // Default route should be string + expect(typeof DEFAULT_ROUTE).toBe("string"); + }); + + it("should have non-empty values", () => { + // All route names should be non-empty + Object.values(ROUTE_NAMES).forEach((value) => { + expect(value).toBeTruthy(); + expect(value.length).toBeGreaterThan(0); + }); + + // All meta keys should be non-empty + Object.values(META_KEYS).forEach((value) => { + expect(value).toBeTruthy(); + expect(value.length).toBeGreaterThan(0); + }); + + // All paths should be non-empty + expect(ROUTE_PATHS.ROOT).toBeTruthy(); + expect(ROUTE_PATHS.MARKETPLACE).toBeTruthy(); + expect(ROUTE_PATHS.ALARM).toBeTruthy(); + expect(ROUTE_PATHS.SETTINGS).toBeTruthy(); + }); + }); +}); diff --git a/src/router/__tests__/guards.spec.ts b/src/router/__tests__/guards.spec.ts new file mode 100644 index 00000000..e3d073a8 --- /dev/null +++ b/src/router/__tests__/guards.spec.ts @@ -0,0 +1,253 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRootGuard, createAuthGuard, createValidationGuard, createErrorGuard, applyGuards } from "../guards"; +import { getDefaultRoute } from "../utils"; +import { ROUTE_PATHS } from "../constants"; + +// Mock utils +vi.mock("../utils", () => ({ + getDefaultRoute: vi.fn(), +})); + +describe("Router Guards", () => { + const mockNext = vi.fn(); + const mockRoutes = [ + { path: "/marketplace", name: "Marketplace" }, + { path: "/dashboard", name: "Dashboard" }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + (getDefaultRoute as any).mockReturnValue("/marketplace"); + }); + + describe("createRootGuard", () => { + it("should redirect root path to default route", () => { + const rootGuard = createRootGuard(mockRoutes); + const to = { path: ROUTE_PATHS.ROOT }; + const from = { path: "/some-path" }; + + rootGuard(to, from, mockNext); + + expect(getDefaultRoute).toHaveBeenCalledWith(mockRoutes); + expect(mockNext).toHaveBeenCalledWith({ path: "/marketplace" }); + }); + + it("should allow non-root paths to pass through", () => { + const rootGuard = createRootGuard(mockRoutes); + const to = { path: "/dashboard" }; + const from = { path: "/some-path" }; + + rootGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it("should handle different default routes", () => { + (getDefaultRoute as any).mockReturnValue("/dashboard"); + const rootGuard = createRootGuard(mockRoutes); + const to = { path: ROUTE_PATHS.ROOT }; + const from = { path: "/some-path" }; + + rootGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith({ path: "/dashboard" }); + }); + }); + + describe("createAuthGuard", () => { + it("should allow all routes to pass through (placeholder implementation)", () => { + const authGuard = createAuthGuard(); + const to = { path: "/protected", meta: { requiresAuth: true } }; + const from = { path: "/some-path" }; + + authGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it("should handle routes without auth requirements", () => { + const authGuard = createAuthGuard(); + const to = { path: "/public", meta: {} }; + const from = { path: "/some-path" }; + + authGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it("should handle routes with requiresAuth: false", () => { + const authGuard = createAuthGuard(); + const to = { path: "/public", meta: { requiresAuth: false } }; + const from = { path: "/some-path" }; + + authGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe("createValidationGuard", () => { + it("should allow routes without parameters to pass through", () => { + const validationGuard = createValidationGuard(); + const to = { path: "/simple", params: {} }; + const from = { path: "/some-path" }; + + validationGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it("should allow routes with valid parameters to pass through", () => { + const validationGuard = createValidationGuard(); + const to = { path: "/valid", params: { id: "123", name: "test" } }; + const from = { path: "/some-path" }; + + validationGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it("should redirect to NotFound for routes with invalid parameters", () => { + const validationGuard = createValidationGuard(); + const to = { path: "/invalid", params: { id: "", name: null } }; + const from = { path: "/some-path" }; + + validationGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith({ name: "NotFound" }); + }); + + it("should redirect to NotFound for routes with undefined parameters", () => { + const validationGuard = createValidationGuard(); + const to = { path: "/invalid", params: { id: undefined } }; + const from = { path: "/some-path" }; + + validationGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith({ name: "NotFound" }); + }); + + it("should handle mixed valid and invalid parameters", () => { + const validationGuard = createValidationGuard(); + const to = { path: "/mixed", params: { id: "123", name: "" } }; + const from = { path: "/some-path" }; + + validationGuard(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledWith({ name: "NotFound" }); + }); + }); + + describe("createErrorGuard", () => { + it("should handle NavigationDuplicated errors silently", () => { + const errorGuard = createErrorGuard(); + const error = { name: "NavigationDuplicated" }; + + expect(() => errorGuard(error)).not.toThrow(); + }); + + it("should re-throw non-NavigationDuplicated errors", () => { + const errorGuard = createErrorGuard(); + const error = { name: "OtherError", message: "Something went wrong" }; + + expect(() => errorGuard(error)).toThrow(); + }); + + it("should log router errors", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorGuard = createErrorGuard(); + const error = { name: "TestError", message: "Test error" }; + + try { + errorGuard(error); + } catch { + // Expected to throw + } + + expect(consoleSpy).toHaveBeenCalledWith("Router error:", error); + consoleSpy.mockRestore(); + }); + }); + + describe("applyGuards", () => { + it("should apply all navigation guards to router", () => { + const mockRouter = { + beforeEach: vi.fn(), + onError: vi.fn(), + }; + + applyGuards(mockRouter, mockRoutes); + + expect(mockRouter.beforeEach).toHaveBeenCalledTimes(3); + expect(mockRouter.onError).toHaveBeenCalledTimes(1); + }); + + it("should apply guards in correct order", () => { + const mockRouter = { + beforeEach: vi.fn(), + onError: vi.fn(), + }; + + applyGuards(mockRouter, mockRoutes); + + // Verify the order: rootGuard, authGuard, validationGuard + const calls = mockRouter.beforeEach.mock.calls; + expect(calls).toHaveLength(3); + }); + + it("should apply error guard", () => { + const mockRouter = { + beforeEach: vi.fn(), + onError: vi.fn(), + }; + + applyGuards(mockRouter, mockRoutes); + + expect(mockRouter.onError).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("Guard Integration", () => { + it("should work together without conflicts", () => { + const mockRouter = { + beforeEach: vi.fn(), + onError: vi.fn(), + }; + + // Apply all guards + applyGuards(mockRouter, mockRoutes); + + // Test root guard + const rootGuard = mockRouter.beforeEach.mock.calls[0][0]; + const to = { path: ROUTE_PATHS.ROOT }; + const from = { path: "/some-path" }; + + rootGuard(to, from, mockNext); + expect(mockNext).toHaveBeenCalledWith({ path: "/marketplace" }); + + // Test validation guard + const validationGuard = mockRouter.beforeEach.mock.calls[2][0]; + const validTo = { path: "/valid", params: { id: "123" } }; + + validationGuard(validTo, from, mockNext); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); +}); diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts new file mode 100644 index 00000000..5cae65f2 --- /dev/null +++ b/src/router/__tests__/index.spec.ts @@ -0,0 +1,263 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from "vitest"; +import { META_KEYS } from "../constants"; + +// Mock route modules to avoid Vue component import issues +vi.mock("../dashboard", () => ({ + routesDashboard: [ + { + name: "Dashboard", + path: "/dashboard", + meta: { + title: "Dashboards", + i18nKey: "dashboards", + icon: "dashboard_customize", + hasGroup: true, + activate: true, + breadcrumb: true, + }, + }, + ], +})); + +vi.mock("../marketplace", () => ({ + routesMarketplace: [ + { + name: "Marketplace", + path: "/marketplace", + meta: { + title: "Marketplace", + i18nKey: "marketplace", + icon: "marketplace", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], +})); + +vi.mock("../alarm", () => ({ + routesAlarm: [ + { + name: "Alarm", + path: "/alarm", + meta: { + title: "Alarm", + i18nKey: "alarm", + icon: "alarm", + hasGroup: true, + activate: true, + breadcrumb: true, + }, + }, + ], +})); + +vi.mock("../layer", () => ({ + default: [ + { + name: "Layer", + path: "/layer", + meta: { + title: "Layer", + i18nKey: "layer", + icon: "layers", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], +})); + +vi.mock("../settings", () => ({ + routesSettings: [ + { + name: "Settings", + path: "/settings", + meta: { + title: "Settings", + i18nKey: "settings", + icon: "settings", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], +})); + +vi.mock("../notFound", () => ({ + routesNotFound: [ + { + name: "NotFound", + path: "/:pathMatch(.*)*", + meta: { + title: "Not Found", + i18nKey: "notFound", + icon: "error", + hasGroup: false, + activate: false, + breadcrumb: false, + }, + }, + ], +})); + +// Mock guards +vi.mock("../guards", () => ({ + applyGuards: vi.fn(), +})); + +// Mock environment +vi.mock("import.meta.env", () => ({ + BASE_URL: "/", +})); + +// Import after mocks +import { routes } from "../index"; + +describe("Router Index - Route Structure", () => { + describe("Route Configuration", () => { + it("should combine all route modules correctly", () => { + expect(routes).toEqual([ + expect.objectContaining({ name: "Marketplace" }), + expect.objectContaining({ name: "Layer" }), + expect.objectContaining({ name: "Alarm" }), + expect.objectContaining({ name: "Dashboard" }), + expect.objectContaining({ name: "Settings" }), + expect.objectContaining({ name: "NotFound" }), + ]); + }); + + it("should include marketplace routes", () => { + expect(routes).toContainEqual( + expect.objectContaining({ + name: "Marketplace", + }), + ); + }); + + it("should include dashboard routes", () => { + expect(routes).toContainEqual( + expect.objectContaining({ + name: "Dashboard", + }), + ); + }); + + it("should include alarm routes", () => { + expect(routes).toContainEqual( + expect.objectContaining({ + name: "Alarm", + }), + ); + }); + + it("should include settings routes", () => { + expect(routes).toContainEqual( + expect.objectContaining({ + name: "Settings", + }), + ); + }); + + it("should include not found routes", () => { + expect(routes).toContainEqual( + expect.objectContaining({ + name: "NotFound", + }), + ); + }); + }); + + describe("Route Export", () => { + it("should export routes array", () => { + expect(routes).toBeDefined(); + expect(Array.isArray(routes)).toBe(true); + }); + }); + + describe("Route Structure Validation", () => { + it("should have valid route structure", () => { + routes.forEach((route) => { + expect(route).toHaveProperty("name"); + expect(route).toHaveProperty("meta"); + expect(route.meta).toHaveProperty("title"); + }); + }); + + it("should have proper meta structure", () => { + routes.forEach((route) => { + expect(route.meta).toHaveProperty("i18nKey"); + expect(route.meta).toHaveProperty("icon"); + expect(route.meta).toHaveProperty("hasGroup"); + expect(route.meta).toHaveProperty("activate"); + expect(route.meta).toHaveProperty("breadcrumb"); + }); + }); + }); + + describe("Route Metadata Validation", () => { + it("should have correct marketplace metadata", () => { + const marketplaceRoute = routes.find((r) => r.name === "Marketplace"); + expect(marketplaceRoute).toBeDefined(); + expect(marketplaceRoute?.meta[META_KEYS.TITLE]).toBe("Marketplace"); + expect(marketplaceRoute?.meta[META_KEYS.I18N_KEY]).toBe("marketplace"); + expect(marketplaceRoute?.meta[META_KEYS.ICON]).toBe("marketplace"); + expect(marketplaceRoute?.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(marketplaceRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(marketplaceRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have correct dashboard metadata", () => { + const dashboardRoute = routes.find((r) => r.name === "Dashboard"); + expect(dashboardRoute).toBeDefined(); + expect(dashboardRoute?.meta[META_KEYS.TITLE]).toBe("Dashboards"); + expect(dashboardRoute?.meta[META_KEYS.I18N_KEY]).toBe("dashboards"); + expect(dashboardRoute?.meta[META_KEYS.ICON]).toBe("dashboard_customize"); + expect(dashboardRoute?.meta[META_KEYS.HAS_GROUP]).toBe(true); + expect(dashboardRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(dashboardRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have correct alarm metadata", () => { + const alarmRoute = routes.find((r) => r.name === "Alarm"); + expect(alarmRoute).toBeDefined(); + expect(alarmRoute?.meta[META_KEYS.TITLE]).toBe("Alarm"); + expect(alarmRoute?.meta[META_KEYS.I18N_KEY]).toBe("alarm"); + expect(alarmRoute?.meta[META_KEYS.ICON]).toBe("alarm"); + expect(alarmRoute?.meta[META_KEYS.HAS_GROUP]).toBe(true); + expect(alarmRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(alarmRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have correct not found metadata", () => { + const notFoundRoute = routes.find((r) => r.name === "NotFound"); + expect(notFoundRoute).toBeDefined(); + expect(notFoundRoute?.meta[META_KEYS.TITLE]).toBe("Not Found"); + expect(notFoundRoute?.meta[META_KEYS.I18N_KEY]).toBe("notFound"); + expect(notFoundRoute?.meta[META_KEYS.ICON]).toBe("error"); + expect(notFoundRoute?.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(notFoundRoute?.meta[META_KEYS.ACTIVATE]).toBe(false); + expect(notFoundRoute?.meta[META_KEYS.BREADCRUMB]).toBe(false); + }); + }); +}); diff --git a/src/router/__tests__/route-modules.spec.ts b/src/router/__tests__/route-modules.spec.ts new file mode 100644 index 00000000..67a884b7 --- /dev/null +++ b/src/router/__tests__/route-modules.spec.ts @@ -0,0 +1,518 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi } from "vitest"; +import { ROUTE_NAMES, META_KEYS } from "../constants"; +import type { AppRouteRecordRaw } from "@/types/router"; + +// Mock route modules to avoid Vue component import issues +vi.mock("../dashboard", () => ({ + routesDashboard: [ + { + name: "Dashboard", + path: "/dashboard", + meta: { + title: "Dashboards", + i18nKey: "dashboards", + icon: "dashboard_customize", + hasGroup: true, + activate: true, + breadcrumb: true, + }, + children: [ + { + name: "DashboardList", + path: "/dashboard/list", + meta: { + title: "Dashboard List", + i18nKey: "dashboardList", + icon: "list", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + { + name: "DashboardNew", + path: "/dashboard/new", + meta: { + title: "New Dashboard", + i18nKey: "dashboardNew", + icon: "add", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + { + name: "DashboardEdit", + path: "/dashboard/edit/:id", + meta: { + title: "Edit Dashboard", + i18nKey: "dashboardEdit", + icon: "edit", + hasGroup: false, + activate: false, + breadcrumb: true, + }, + }, + ], + }, + ], +})); + +vi.mock("../marketplace", () => ({ + routesMarketplace: [ + { + name: "Marketplace", + path: "/marketplace", + meta: { + title: "Marketplace", + i18nKey: "marketplace", + icon: "marketplace", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + children: [ + { + name: "MenusManagement", + path: "", // Empty path for child route + meta: { + title: "Marketplace", + i18nKey: "menusManagement", + icon: "menu", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], + }, + ], +})); + +vi.mock("../alarm", () => ({ + routesAlarm: [ + { + name: "Alarm", + path: "/alarm", + meta: { + title: "Alarm", + i18nKey: "alarm", + icon: "alarm", + hasGroup: true, + activate: true, + breadcrumb: true, + }, + children: [ + { + name: "AlarmList", + path: "/alarm/list", + meta: { + title: "Alarm List", + i18nKey: "alarmList", + icon: "list", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + { + name: "AlarmNew", + path: "/alarm/new", + meta: { + title: "New Alarm", + i18nKey: "alarmNew", + icon: "add", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], + }, + ], +})); + +vi.mock("../layer", () => ({ + default: [ + { + name: "Layer", + path: "/layer", + meta: { + title: "Layer", + i18nKey: "layer", + icon: "layers", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + children: [ + { + name: "LayerList", + path: "/layer/list", + meta: { + title: "Layer List", + i18nKey: "layerList", + icon: "list", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], + }, + ], +})); + +vi.mock("../settings", () => ({ + routesSettings: [ + { + name: "Settings", + path: "/settings", + meta: { + title: "Settings", + i18nKey: "settings", + icon: "settings", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + children: [ + { + name: "SettingsGeneral", + path: "/settings/general", + meta: { + title: "General Settings", + i18nKey: "settingsGeneral", + icon: "settings", + hasGroup: false, + activate: true, + breadcrumb: true, + }, + }, + ], + }, + ], +})); + +vi.mock("../notFound", () => ({ + routesNotFound: [ + { + name: "NotFound", + path: "/:pathMatch(.*)*", + meta: { + title: "Not Found", + i18nKey: "notFound", + icon: "error", + hasGroup: false, + activate: false, + breadcrumb: false, + }, + }, + ], +})); + +// Import after mocks +import { routesDashboard } from "../dashboard"; +import { routesMarketplace } from "../marketplace"; +import { routesAlarm } from "../alarm"; +import routesLayers from "../layer"; +import { routesSettings } from "../settings"; +import { routesNotFound } from "../notFound"; + +describe("Route Modules", () => { + describe("Marketplace Routes", () => { + it("should export marketplace routes", () => { + expect(routesMarketplace).toBeDefined(); + expect(Array.isArray(routesMarketplace)).toBe(true); + }); + + it("should have correct marketplace route structure", () => { + const marketplaceRoute = routesMarketplace[0]; + expect(marketplaceRoute.name).toBe(ROUTE_NAMES.MARKETPLACE); + expect(marketplaceRoute.meta[META_KEYS.I18N_KEY]).toBe("marketplace"); + expect(marketplaceRoute.meta[META_KEYS.ICON]).toBe("marketplace"); + expect(marketplaceRoute.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(marketplaceRoute.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(marketplaceRoute.meta[META_KEYS.TITLE]).toBe("Marketplace"); + expect(marketplaceRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have marketplace child route", () => { + const marketplaceRoute = routesMarketplace[0]; + expect(marketplaceRoute.children).toBeDefined(); + expect(marketplaceRoute.children).toHaveLength(1); + + const childRoute = marketplaceRoute.children![0]; + expect(childRoute.path).toBe(""); // Empty path for child route + expect(childRoute.name).toBe("MenusManagement"); + expect(childRoute.meta[META_KEYS.TITLE]).toBe("Marketplace"); + expect(childRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + }); + + describe("Dashboard Routes", () => { + it("should export dashboard routes", () => { + expect(routesDashboard).toBeDefined(); + expect(Array.isArray(routesDashboard)).toBe(true); + }); + + it("should have correct dashboard route structure", () => { + const dashboardRoute = routesDashboard[0]; + expect(dashboardRoute.name).toBe(ROUTE_NAMES.DASHBOARD); + expect(dashboardRoute.meta[META_KEYS.I18N_KEY]).toBe("dashboards"); + expect(dashboardRoute.meta[META_KEYS.ICON]).toBe("dashboard_customize"); + expect(dashboardRoute.meta[META_KEYS.HAS_GROUP]).toBe(true); + expect(dashboardRoute.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(dashboardRoute.meta[META_KEYS.TITLE]).toBe("Dashboards"); + expect(dashboardRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have dashboard list route", () => { + const dashboardRoute = routesDashboard[0]; + const listRoute = dashboardRoute.children?.find((r) => r.name === "DashboardList"); + + expect(listRoute).toBeDefined(); + expect(listRoute?.path).toBe("/dashboard/list"); + expect(listRoute?.meta[META_KEYS.I18N_KEY]).toBe("dashboardList"); + expect(listRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(listRoute?.meta[META_KEYS.TITLE]).toBe("Dashboard List"); + expect(listRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have dashboard new route", () => { + const dashboardRoute = routesDashboard[0]; + const newRoute = dashboardRoute.children?.find((r) => r.name === "DashboardNew"); + + expect(newRoute).toBeDefined(); + expect(newRoute?.path).toBe("/dashboard/new"); + expect(newRoute?.meta[META_KEYS.I18N_KEY]).toBe("dashboardNew"); + expect(newRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(newRoute?.meta[META_KEYS.TITLE]).toBe("New Dashboard"); + expect(newRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have dashboard edit routes", () => { + const dashboardRoute = routesDashboard[0]; + const editRoute = dashboardRoute.children?.find((r) => r.name === "DashboardEdit"); + + expect(editRoute).toBeDefined(); + expect(editRoute?.path).toBe("/dashboard/edit/:id"); + expect(editRoute?.meta[META_KEYS.I18N_KEY]).toBe("dashboardEdit"); + expect(editRoute?.meta[META_KEYS.ACTIVATE]).toBe(false); + expect(editRoute?.meta[META_KEYS.TITLE]).toBe("Edit Dashboard"); + expect(editRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + }); + + describe("Alarm Routes", () => { + it("should export alarm routes", () => { + expect(routesAlarm).toBeDefined(); + expect(Array.isArray(routesAlarm)).toBe(true); + }); + + it("should have correct alarm route structure", () => { + const alarmRoute = routesAlarm[0]; + expect(alarmRoute.name).toBe(ROUTE_NAMES.ALARM); + expect(alarmRoute.meta[META_KEYS.I18N_KEY]).toBe("alarm"); + expect(alarmRoute.meta[META_KEYS.ICON]).toBe("alarm"); + expect(alarmRoute.meta[META_KEYS.HAS_GROUP]).toBe(true); + expect(alarmRoute.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(alarmRoute.meta[META_KEYS.TITLE]).toBe("Alarm"); + expect(alarmRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have alarm list route", () => { + const alarmRoute = routesAlarm[0]; + const listRoute = alarmRoute.children?.find((r) => r.name === "AlarmList"); + + expect(listRoute).toBeDefined(); + expect(listRoute?.path).toBe("/alarm/list"); + expect(listRoute?.meta[META_KEYS.I18N_KEY]).toBe("alarmList"); + expect(listRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(listRoute?.meta[META_KEYS.TITLE]).toBe("Alarm List"); + expect(listRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have alarm new route", () => { + const alarmRoute = routesAlarm[0]; + const newRoute = alarmRoute.children?.find((r) => r.name === "AlarmNew"); + + expect(newRoute).toBeDefined(); + expect(newRoute?.path).toBe("/alarm/new"); + expect(newRoute?.meta[META_KEYS.I18N_KEY]).toBe("alarmNew"); + expect(newRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(newRoute?.meta[META_KEYS.TITLE]).toBe("New Alarm"); + expect(newRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + }); + + describe("Layer Routes", () => { + it("should export layer routes", () => { + expect(routesLayers).toBeDefined(); + expect(Array.isArray(routesLayers)).toBe(true); + }); + + it("should have correct layer route structure", () => { + const layerRoute = routesLayers[0]; + expect(layerRoute.name).toBe(ROUTE_NAMES.LAYER); + expect(layerRoute.meta[META_KEYS.I18N_KEY]).toBe("layer"); + expect(layerRoute.meta[META_KEYS.ICON]).toBe("layers"); + expect(layerRoute.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(layerRoute.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(layerRoute.meta[META_KEYS.TITLE]).toBe("Layer"); + expect(layerRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have layer list route", () => { + const layerRoute = routesLayers[0]; + const listRoute = layerRoute.children?.find((r) => r.name === "LayerList"); + + expect(listRoute).toBeDefined(); + expect(listRoute?.path).toBe("/layer/list"); + expect(listRoute?.meta[META_KEYS.I18N_KEY]).toBe("layerList"); + expect(listRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(listRoute?.meta[META_KEYS.TITLE]).toBe("Layer List"); + expect(listRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + }); + + describe("Settings Routes", () => { + it("should export settings routes", () => { + expect(routesSettings).toBeDefined(); + expect(Array.isArray(routesSettings)).toBe(true); + }); + + it("should have correct settings route structure", () => { + const settingsRoute = routesSettings[0]; + expect(settingsRoute.name).toBe(ROUTE_NAMES.SETTINGS); + expect(settingsRoute.meta[META_KEYS.I18N_KEY]).toBe("settings"); + expect(settingsRoute.meta[META_KEYS.ICON]).toBe("settings"); + expect(settingsRoute.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(settingsRoute.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(settingsRoute.meta[META_KEYS.TITLE]).toBe("Settings"); + expect(settingsRoute.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + + it("should have settings general route", () => { + const settingsRoute = routesSettings[0]; + const generalRoute = settingsRoute.children?.find((r) => r.name === "SettingsGeneral"); + + expect(generalRoute).toBeDefined(); + expect(generalRoute?.path).toBe("/settings/general"); + expect(generalRoute?.meta[META_KEYS.I18N_KEY]).toBe("settingsGeneral"); + expect(generalRoute?.meta[META_KEYS.ACTIVATE]).toBe(true); + expect(generalRoute?.meta[META_KEYS.TITLE]).toBe("General Settings"); + expect(generalRoute?.meta[META_KEYS.BREADCRUMB]).toBe(true); + }); + }); + + describe("Not Found Routes", () => { + it("should export not found routes", () => { + expect(routesNotFound).toBeDefined(); + expect(Array.isArray(routesNotFound)).toBe(true); + }); + + it("should have correct not found route structure", () => { + const notFoundRoute = routesNotFound[0]; + expect(notFoundRoute.name).toBe(ROUTE_NAMES.NOT_FOUND); + expect(notFoundRoute.path).toBe("/:pathMatch(.*)*"); + expect(notFoundRoute.meta[META_KEYS.I18N_KEY]).toBe("notFound"); + expect(notFoundRoute.meta[META_KEYS.ICON]).toBe("error"); + expect(notFoundRoute.meta[META_KEYS.HAS_GROUP]).toBe(false); + expect(notFoundRoute.meta[META_KEYS.ACTIVATE]).toBe(false); + expect(notFoundRoute.meta[META_KEYS.BREADCRUMB]).toBe(false); + }); + }); + + describe("Route Uniqueness", () => { + it("should have unique route names across all modules", () => { + const allRoutes = [ + ...routesMarketplace, + ...routesLayers, + ...routesAlarm, + ...routesDashboard, + ...routesSettings, + ...routesNotFound, + ]; + + const routeNames = allRoutes.map((r) => r.name); + const uniqueNames = new Set(routeNames); + + expect(uniqueNames.size).toBe(routeNames.length); + }); + + it("should have unique route paths across all modules", () => { + const allRoutes = [ + ...routesMarketplace, + ...routesLayers, + ...routesAlarm, + ...routesDashboard, + ...routesSettings, + ...routesNotFound, + ]; + + const getAllPaths = (routes: AppRouteRecordRaw[]): string[] => { + const paths: string[] = []; + routes.forEach((route) => { + if (route.path) { + paths.push(route.path); + } + if (route.children) { + paths.push(...getAllPaths(route.children)); + } + }); + return paths; + }; + + const allPaths = getAllPaths(allRoutes); + const uniquePaths = new Set(allPaths); + + expect(uniquePaths.size).toBe(allPaths.length); + }); + }); + + describe("Route Metadata Consistency", () => { + it("should have consistent meta structure across all routes", () => { + const allRoutes = [ + ...routesMarketplace, + ...routesLayers, + ...routesAlarm, + ...routesDashboard, + ...routesSettings, + ...routesNotFound, + ]; + + const validateRouteMeta = (route: AppRouteRecordRaw) => { + expect(route.meta).toHaveProperty(META_KEYS.TITLE); + expect(route.meta).toHaveProperty(META_KEYS.I18N_KEY); + expect(route.meta).toHaveProperty(META_KEYS.ICON); + expect(route.meta).toHaveProperty(META_KEYS.HAS_GROUP); + expect(route.meta).toHaveProperty(META_KEYS.ACTIVATE); + expect(route.meta).toHaveProperty(META_KEYS.BREADCRUMB); + + if (route.children) { + route.children.forEach(validateRouteMeta); + } + }; + + allRoutes.forEach(validateRouteMeta); + }); + }); +}); diff --git a/src/router/__tests__/utils.spec.ts b/src/router/__tests__/utils.spec.ts new file mode 100644 index 00000000..6fb6dbf8 --- /dev/null +++ b/src/router/__tests__/utils.spec.ts @@ -0,0 +1,465 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + findActivatedRoute, + getDefaultRoute, + requiresAuth, + generateBreadcrumb, + validateRoute, + flattenRoutes, +} from "../utils"; +import { DEFAULT_ROUTE } from "../constants"; +import type { AppRouteRecordRaw } from "@/types/router"; + +describe("Router Utils", () => { + const mockRoutes: AppRouteRecordRaw[] = [ + { + path: "/marketplace", + name: "Marketplace", + component: {}, + meta: { + title: "Marketplace", + activate: false, + breadcrumb: true, + }, + }, + { + path: "/dashboard", + name: "Dashboard", + component: {}, + meta: { + title: "Dashboard", + activate: false, + breadcrumb: true, + }, + children: [ + { + path: "/dashboard/list", + name: "DashboardList", + component: {}, + meta: { + title: "Dashboard List", + activate: true, + breadcrumb: true, + }, + }, + { + path: "/dashboard/new", + name: "DashboardNew", + component: {}, + meta: { + title: "Dashboard New", + activate: false, + breadcrumb: false, + }, + }, + ], + }, + { + path: "/settings", + name: "Settings", + component: {}, + meta: { + title: "Settings", + activate: false, + breadcrumb: true, + requiresAuth: true, + }, + }, + ]; + + describe("findActivatedRoute", () => { + it("should find first activated route from nested routes", () => { + const result = findActivatedRoute(mockRoutes); + expect(result).toBe("/dashboard/list"); + }); + + it("should find activated route from children", () => { + const result = findActivatedRoute(mockRoutes); + expect(result).toBe("/dashboard/list"); + }); + + it("should return null when no activated routes exist", () => { + const routesWithoutActivate: AppRouteRecordRaw[] = [ + { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + activate: false, + breadcrumb: true, + }, + }, + ]; + + const result = findActivatedRoute(routesWithoutActivate); + expect(result).toBeNull(); + }); + + it("should handle routes with no meta", () => { + const routesWithoutMeta: AppRouteRecordRaw[] = [ + { + path: "/test", + name: "Test", + component: {}, + meta: {}, + }, + ]; + + const result = findActivatedRoute(routesWithoutMeta); + expect(result).toBeNull(); + }); + }); + + describe("getDefaultRoute", () => { + it("should return activated route when available", () => { + const result = getDefaultRoute(mockRoutes); + expect(result).toBe("/dashboard/list"); + }); + + it("should return DEFAULT_ROUTE when no activated routes exist", () => { + const routesWithoutActivate: AppRouteRecordRaw[] = [ + { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + activate: false, + breadcrumb: true, + }, + }, + ]; + + const result = getDefaultRoute(routesWithoutActivate); + expect(result).toBe(DEFAULT_ROUTE); + }); + + it("should handle empty routes array", () => { + const result = getDefaultRoute([]); + expect(result).toBe(DEFAULT_ROUTE); + }); + }); + + describe("requiresAuth", () => { + it("should return true for routes requiring authentication", () => { + const authRoute = mockRoutes[2]; // Settings route + const result = requiresAuth(authRoute); + expect(result).toBe(true); + }); + + it("should return false for routes not requiring authentication", () => { + const publicRoute = mockRoutes[0]; // Marketplace route + const result = requiresAuth(publicRoute); + expect(result).toBe(false); + }); + + it("should return false for routes without requiresAuth meta", () => { + const routeWithoutAuth: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + }; + + const result = requiresAuth(routeWithoutAuth); + expect(result).toBe(false); + }); + + it("should return false for routes with requiresAuth: false", () => { + const routeWithFalseAuth: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + requiresAuth: false, + breadcrumb: true, + }, + }; + + const result = requiresAuth(routeWithFalseAuth); + expect(result).toBe(false); + }); + }); + + describe("generateBreadcrumb", () => { + it("should generate breadcrumb from route with title", () => { + const route = mockRoutes[0]; // Marketplace route + const result = generateBreadcrumb(route); + expect(result).toEqual(["Marketplace"]); + }); + + it("should generate breadcrumb from route with children", () => { + const route = mockRoutes[1]; // Dashboard route + const result = generateBreadcrumb(route); + expect(result).toEqual(["Dashboard", "Dashboard List"]); + }); + + it("should exclude children with breadcrumb: false", () => { + const route = mockRoutes[1]; // Dashboard route + const result = generateBreadcrumb(route); + expect(result).not.toContain("Dashboard New"); + }); + + it("should handle routes without title", () => { + const routeWithoutTitle: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + breadcrumb: true, + }, + }; + + const result = generateBreadcrumb(routeWithoutTitle); + expect(result).toEqual([]); + }); + + it("should handle routes with no meta", () => { + const routeWithoutMeta: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: {}, + }; + + const result = generateBreadcrumb(routeWithoutMeta); + expect(result).toEqual([]); + }); + + it("should handle empty children array", () => { + const routeWithEmptyChildren: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + children: [], + }; + + const result = generateBreadcrumb(routeWithEmptyChildren); + expect(result).toEqual(["Test"]); + }); + }); + + describe("validateRoute", () => { + it("should validate valid route", () => { + const validRoute = mockRoutes[0]; + const result = validateRoute(validRoute); + expect(result).toBe(true); + }); + + it("should validate route with children", () => { + const routeWithChildren = mockRoutes[1]; + const result = validateRoute(routeWithChildren); + expect(result).toBe(true); + }); + + it("should return false for route without name", () => { + const invalidRoute: AppRouteRecordRaw = { + path: "/test", + name: "", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + }; + + const result = validateRoute(invalidRoute); + expect(result).toBe(false); + }); + + it("should return false for route without component", () => { + const invalidRoute: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: null as any, + meta: { + title: "Test", + breadcrumb: true, + }, + }; + + const result = validateRoute(invalidRoute); + expect(result).toBe(false); + }); + + it("should return false for route without path", () => { + const invalidRoute: AppRouteRecordRaw = { + path: "", + name: "Test", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + }; + + const result = validateRoute(invalidRoute); + expect(result).toBe(false); + }); + + it("should return false for route with invalid children", () => { + const routeWithInvalidChildren: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + children: [ + { + path: "/test/child", + name: "", // Invalid: no name + component: {}, + meta: { + title: "Child", + breadcrumb: true, + }, + }, + ], + }; + + const result = validateRoute(routeWithInvalidChildren); + expect(result).toBe(false); + }); + }); + + describe("flattenRoutes", () => { + it("should flatten nested routes into single array", () => { + const result = flattenRoutes(mockRoutes); + expect(result).toHaveLength(5); // 3 parent + 2 children + }); + + it("should preserve route order", () => { + const result = flattenRoutes(mockRoutes); + expect(result[0].name).toBe("Marketplace"); + expect(result[1].name).toBe("Dashboard"); + expect(result[2].name).toBe("DashboardList"); + expect(result[3].name).toBe("DashboardNew"); + expect(result[4].name).toBe("Settings"); + }); + + it("should handle routes without children", () => { + const routesWithoutChildren = [mockRoutes[0], mockRoutes[2]]; + const result = flattenRoutes(routesWithoutChildren); + expect(result).toHaveLength(2); + }); + + it("should handle empty routes array", () => { + const result = flattenRoutes([]); + expect(result).toEqual([]); + }); + + it("should handle deeply nested routes", () => { + const deeplyNestedRoutes: AppRouteRecordRaw[] = [ + { + path: "/level1", + name: "Level1", + component: {}, + meta: { title: "Level 1" }, + children: [ + { + path: "/level1/level2", + name: "Level2", + component: {}, + meta: { title: "Level 2" }, + children: [ + { + path: "/level1/level2/level3", + name: "Level3", + component: {}, + meta: { title: "Level 3" }, + }, + ], + }, + ], + }, + ]; + + const result = flattenRoutes(deeplyNestedRoutes); + expect(result).toHaveLength(3); + expect(result[0].name).toBe("Level1"); + expect(result[1].name).toBe("Level2"); + expect(result[2].name).toBe("Level3"); + }); + }); + + describe("Edge Cases", () => { + it("should handle routes with null/undefined values gracefully", () => { + const routeWithNulls: AppRouteRecordRaw = { + path: "/test", + name: "Test", + component: {}, + meta: { + title: "Test", + breadcrumb: true, + }, + children: [ + { + path: "/test/child", + name: "Child", + component: {}, + meta: { + title: "Child", + breadcrumb: true, + }, + }, + ], + }; + + expect(() => validateRoute(routeWithNulls)).not.toThrow(); + expect(() => generateBreadcrumb(routeWithNulls)).not.toThrow(); + expect(() => flattenRoutes([routeWithNulls])).not.toThrow(); + }); + + it("should handle circular references gracefully", () => { + const route1: AppRouteRecordRaw = { + path: "/route1", + name: "Route1", + component: {}, + meta: { title: "Route 1" }, + children: [], + }; + + const route2: AppRouteRecordRaw = { + path: "/route2", + name: "Route2", + component: {}, + meta: { title: "Route 2" }, + children: [route1], + }; + + // Create circular reference + route1.children = [route2]; + + // Should not cause infinite loops + expect(() => flattenRoutes([route1])).toThrow(); + }); + }); +}); diff --git a/src/router/alarm.ts b/src/router/alarm.ts index 0249b829..d79479c9 100644 --- a/src/router/alarm.ts +++ b/src/router/alarm.ts @@ -14,27 +14,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; +import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants"; import Layout from "@/layout/Index.vue"; import Alarm from "@/views/Alarm.vue"; -export const routesAlarm: Array<RouteRecordRaw> = [ +export const routesAlarm: AppRouteRecordRaw[] = [ { path: "", - name: "Alarm", + name: ROUTE_NAMES.ALARM, meta: { - i18nKey: "alarm", - icon: "spam", - hasGroup: false, - activate: true, - title: "Alerting", + [META_KEYS.I18N_KEY]: "alarm", + [META_KEYS.ICON]: "spam", + [META_KEYS.HAS_GROUP]: false, + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "Alerting", + [META_KEYS.BREADCRUMB]: true, }, component: Layout, children: [ { - path: "/alerting", + path: ROUTE_PATHS.ALARM, name: "ViewAlarm", component: Alarm, + meta: { + [META_KEYS.TITLE]: "Alerting", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, diff --git a/src/router/constants.ts b/src/router/constants.ts new file mode 100644 index 00000000..84ab35b3 --- /dev/null +++ b/src/router/constants.ts @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Route Names +export const ROUTE_NAMES = { + MARKETPLACE: "Marketplace", + DASHBOARD: "Dashboard", + ALARM: "Alarm", + SETTINGS: "Settings", + NOT_FOUND: "NotFound", + LAYER: "Layer", +} as const; + +// Route Paths +export const ROUTE_PATHS = { + ROOT: "/", + MARKETPLACE: "/marketplace", + DASHBOARD: { + LIST: "/dashboard/list", + NEW: "/dashboard/new", + EDIT: "/dashboard/:layerId/:entity/:name", + VIEW: "/dashboard/:layerId/:entity/:serviceId/:name", + WIDGET: + "/page/:layer/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:config/:duration?", + }, + ALARM: "/alerting", + SETTINGS: "/settings", + NOT_FOUND: "/:pathMatch(.*)*", +} as const; + +// Route Meta Keys +export const META_KEYS = { + I18N_KEY: "i18nKey", + ICON: "icon", + HAS_GROUP: "hasGroup", + ACTIVATE: "activate", + TITLE: "title", + DESC_KEY: "descKey", + LAYER: "layer", + NOT_SHOW: "notShow", + REQUIRES_AUTH: "requiresAuth", + BREADCRUMB: "breadcrumb", +} as const; + +// Default Route +export const DEFAULT_ROUTE = ROUTE_PATHS.MARKETPLACE; diff --git a/src/router/dashboard.ts b/src/router/dashboard.ts index b4c29a8c..b2072467 100644 --- a/src/router/dashboard.ts +++ b/src/router/dashboard.ts @@ -14,211 +14,300 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; +import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants"; import Layout from "@/layout/Index.vue"; -import List from "@/views/dashboard/List.vue"; -import New from "@/views/dashboard/New.vue"; -import Edit from "@/views/dashboard/Edit.vue"; -import Widget from "@/views/dashboard/Widget.vue"; -export const routesDashboard: Array<RouteRecordRaw> = [ +// Lazy load components for better performance +const List = () => import("@/views/dashboard/List.vue"); +const New = () => import("@/views/dashboard/New.vue"); +const Edit = () => import("@/views/dashboard/Edit.vue"); +const Widget = () => import("@/views/dashboard/Widget.vue"); + +export const routesDashboard: AppRouteRecordRaw[] = [ { path: "", component: Layout, - name: "Dashboard", + name: ROUTE_NAMES.DASHBOARD, meta: { - i18nKey: "dashboards", - icon: "dashboard_customize", - hasGroup: true, - activate: true, - title: "Dashboards", + [META_KEYS.I18N_KEY]: "dashboards", + [META_KEYS.ICON]: "dashboard_customize", + [META_KEYS.HAS_GROUP]: true, + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "Dashboards", + [META_KEYS.BREADCRUMB]: true, }, children: [ + // Dashboard List { - path: "/dashboard/list", + path: ROUTE_PATHS.DASHBOARD.LIST, component: List, - name: "List", + name: "DashboardList", meta: { - i18nKey: "dashboardList", - activate: true, - title: "Dashboard List", + [META_KEYS.I18N_KEY]: "dashboardList", + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "Dashboard List", + [META_KEYS.BREADCRUMB]: true, }, }, + + // New Dashboard { - path: "/dashboard/new", + path: ROUTE_PATHS.DASHBOARD.NEW, component: New, - name: "New", + name: "DashboardNew", meta: { - i18nKey: "dashboardNew", - activate: true, - title: "New Dashboard", + [META_KEYS.I18N_KEY]: "dashboardNew", + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "New Dashboard", + [META_KEYS.BREADCRUMB]: true, }, }, + + // Dashboard Edit/Create Routes { path: "", - redirect: "/dashboard/:layerId/:entity/:name", - name: "Create", + redirect: ROUTE_PATHS.DASHBOARD.EDIT, + name: "DashboardCreate", component: Edit, meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { - path: "/dashboard/:layerId/:entity/:name", + path: ROUTE_PATHS.DASHBOARD.EDIT, component: Edit, - name: "CreateChild", + name: "DashboardCreateChild", + meta: { + [META_KEYS.TITLE]: "Create Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:name/tab/:activeTabIndex", component: Edit, - name: "CreateActiveTabIndex", + name: "DashboardCreateActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Create Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Dashboard View Routes { path: "", component: Edit, - name: "View", + name: "DashboardView", redirect: "/dashboard/:layerId/:entity/:serviceId/:name", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/:layerId/:entity/:serviceId/:name", component: Edit, - name: "ViewChild", + name: "DashboardViewChild", + meta: { + [META_KEYS.TITLE]: "View Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewActiveTabIndex", + name: "DashboardViewActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "View Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Service Relations Routes { path: "", redirect: "/dashboard/related/:layerId/:entity/:serviceId/:destServiceId/:name", component: Edit, - name: "ServiceRelations", + name: "DashboardServiceRelations", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/related/:layerId/:entity/:serviceId/:destServiceId/:name", component: Edit, - name: "ViewServiceRelation", + name: "DashboardViewServiceRelation", + meta: { + [META_KEYS.TITLE]: "Service Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/related/:layerId/:entity/:serviceId/:destServiceId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewServiceRelationActiveTabIndex", + name: "DashboardViewServiceRelationActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Service Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Pod Routes { path: "", redirect: "/dashboard/:layerId/:entity/:serviceId/:podId/:name", component: Edit, - name: "Pods", + name: "DashboardPods", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:name", component: Edit, - name: "ViewPod", + name: "DashboardViewPod", + meta: { + [META_KEYS.TITLE]: "Pod Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewPodActiveTabIndex", + name: "DashboardViewPodActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Pod Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Process Routes { path: "", redirect: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:name", component: Edit, - name: "Processes", + name: "DashboardProcesses", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:name", component: Edit, - name: "ViewProcess", + name: "DashboardViewProcess", + meta: { + [META_KEYS.TITLE]: "Process Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewProcessActiveTabIndex", + name: "DashboardViewProcessActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Process Dashboard", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Pod Relations Routes { path: "", redirect: "/dashboard/:layerId/:entity/:serviceId/:podId/:destServiceId/:destPodId/:name", component: Edit, - name: "PodRelations", + name: "DashboardPodRelations", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:destServiceId/:destPodId/:name", component: Edit, - name: "ViewPodRelation", + name: "DashboardViewPodRelation", + meta: { + [META_KEYS.TITLE]: "Pod Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:destServiceId/:destPodId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewPodRelationActiveTabIndex", + name: "DashboardViewPodRelationActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Pod Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Process Relations Routes { path: "", redirect: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:name", component: Edit, - name: "ProcessRelations", + name: "DashboardProcessRelations", meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:name", component: Edit, - name: "ViewProcessRelation", + name: "DashboardViewProcessRelation", + meta: { + [META_KEYS.TITLE]: "Process Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:name/tab/:activeTabIndex", component: Edit, - name: "ViewProcessRelationActiveTabIndex", + name: "DashboardViewProcessRelationActiveTabIndex", + meta: { + [META_KEYS.TITLE]: "Process Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, { path: "/dashboard/:layerId/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:name/duration/:duration", component: Edit, - name: "ViewProcessRelationDuration", + name: "DashboardViewProcessRelationDuration", + meta: { + [META_KEYS.TITLE]: "Process Relations", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, + + // Widget Routes { path: "", - name: "Widget", + name: "DashboardWidget", component: Widget, meta: { - notShow: true, + [META_KEYS.NOT_SHOW]: true, }, children: [ { - path: "/page/:layer/:entity/:serviceId/:podId/:processId/:destServiceId/:destPodId/:destProcessId/:config/:duration?", + path: ROUTE_PATHS.DASHBOARD.WIDGET, component: Widget, - name: "ViewWidget", + name: "DashboardViewWidget", + meta: { + [META_KEYS.TITLE]: "Dashboard Widget", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, diff --git a/src/router/guards.ts b/src/router/guards.ts new file mode 100644 index 00000000..34476eb2 --- /dev/null +++ b/src/router/guards.ts @@ -0,0 +1,98 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDefaultRoute } from "./utils"; +import { ROUTE_PATHS } from "./constants"; + +/** + * Global navigation guard for handling root path redirects + */ +export function createRootGuard(routes: any[]) { + return function rootGuard(to: any, from: any, next: any) { + if (to.path === ROUTE_PATHS.ROOT) { + const defaultPath = getDefaultRoute(routes); + next({ path: defaultPath }); + } else { + next(); + } + }; +} + +/** + * Authentication guard (placeholder for future implementation) + */ +export function createAuthGuard() { + return function authGuard(to: any, from: any, next: any) { + // TODO: Implement authentication logic + // const token = window.localStorage.getItem("skywalking-authority"); + // if (to.meta?.requiresAuth && !token) { + // next('/login'); + // return; + // } + next(); + }; +} + +/** + * Route validation guard + */ +export function createValidationGuard() { + return function validationGuard(to: any, from: any, next: any) { + // Validate route parameters if needed + if (to.params && Object.keys(to.params).length > 0) { + // Add custom validation logic here + const hasValidParams = Object.values(to.params).every( + (param) => param !== undefined && param !== null && param !== "", + ); + + if (!hasValidParams) { + next({ name: "NotFound" }); + return; + } + } + + next(); + }; +} + +/** + * Error handling guard + */ +export function createErrorGuard() { + return function errorGuard(error: any) { + console.error("Router error:", error); + + // Handle specific error types + if (error.name === "NavigationDuplicated") { + // Ignore duplicate navigation errors + return; + } + + // Redirect to error page or handle other errors + throw error; + }; +} + +/** + * Apply all navigation guards + */ +export function applyGuards(router: any, routes: any[]) { + router.beforeEach(createRootGuard(routes)); + router.beforeEach(createAuthGuard()); + router.beforeEach(createValidationGuard()); + router.onError(createErrorGuard()); +} diff --git a/src/router/index.ts b/src/router/index.ts index cee1ee6e..523d97d0 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -14,8 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; import { createRouter, createWebHistory } from "vue-router"; +import { applyGuards } from "./guards"; import { routesDashboard } from "./dashboard"; import { routesMarketplace } from "./marketplace"; import { routesAlarm } from "./alarm"; @@ -23,7 +24,10 @@ import routesLayers from "./layer"; import { routesSettings } from "./settings"; import { routesNotFound } from "./notFound"; -const routes: RouteRecordRaw[] = [ +/** + * Combine all route configurations + */ +export const routes: AppRouteRecordRaw[] = [ ...routesMarketplace, ...routesLayers, ...routesAlarm, @@ -32,36 +36,17 @@ const routes: RouteRecordRaw[] = [ ...routesNotFound, ]; +/** + * Create router instance + */ const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes, + routes: routes as any, }); -router.beforeEach((to, _, next) => { - // const token = window.localStorage.getItem("skywalking-authority"); - - if (to.path === "/") { - let defaultPath = ""; - for (const route of routesLayers) { - for (const child of route.children) { - if (child.meta.activate) { - defaultPath = child.path; - break; - } - } - if (defaultPath) { - break; - } - } - - if (!defaultPath) { - defaultPath = "/marketplace"; - } - - next({ path: defaultPath }); - } else { - next(); - } -}); +/** + * Apply navigation guards + */ +applyGuards(router, routes); export default router; diff --git a/src/router/layer.ts b/src/router/layer.ts index 1791f480..51333787 100644 --- a/src/router/layer.ts +++ b/src/router/layer.ts @@ -14,74 +14,93 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import type { AppRouteRecordRaw } from "@/types/router"; +import { META_KEYS } from "./constants"; import Layout from "@/layout/Index.vue"; import { useAppStoreWithOut } from "@/store/modules/app"; import type { MenuOptions } from "@/types/app"; import Layer from "@/views/Layer.vue"; -function layerDashboards() { +/** + * Generate layer dashboard routes from app store menu configuration + */ +function generateLayerDashboards(): AppRouteRecordRaw[] { const appStore = useAppStoreWithOut(); - const routes = appStore.allMenus.map((item: MenuOptions) => { - const route: any = { + + return appStore.allMenus.map((item: MenuOptions): AppRouteRecordRaw => { + const route: AppRouteRecordRaw = { path: "", name: item.name, component: Layout, meta: { - icon: item.icon || "cloud_queue", - title: item.title, - hasGroup: item.hasGroup, - activate: item.activate, - descKey: item.descKey, - i18nKey: item.i18nKey, + [META_KEYS.ICON]: item.icon || "cloud_queue", + [META_KEYS.TITLE]: item.title, + [META_KEYS.HAS_GROUP]: item.hasGroup, + [META_KEYS.ACTIVATE]: item.activate, + [META_KEYS.DESC_KEY]: item.descKey, + [META_KEYS.I18N_KEY]: item.i18nKey, + [META_KEYS.BREADCRUMB]: true, }, children: item.subItems && item.subItems.length ? [] : undefined, }; - for (const child of item.subItems || []) { - const d = { - name: child.name, - path: child.path, - meta: { - title: child.title, - layer: child.layer, - icon: child.icon || "cloud_queue", - activate: child.activate, - descKey: child.descKey, - i18nKey: child.i18nKey, - }, - component: Layer, - }; - route.children.push(d); - const tab = { - name: `${child.name}ActiveTabIndex`, - path: `/${child.path}/tab/:activeTabIndex`, - component: Layer, - meta: { - notShow: true, - layer: child.layer, - }, - }; - route.children.push(tab); - } - if (!item.hasGroup) { + + // Handle grouped items + if (item.subItems && item.subItems.length) { + for (const child of item.subItems) { + const childRoute: AppRouteRecordRaw = { + name: child.name, + path: child.path || "", + meta: { + [META_KEYS.TITLE]: child.title, + [META_KEYS.LAYER]: child.layer, + [META_KEYS.ICON]: child.icon || "cloud_queue", + [META_KEYS.ACTIVATE]: child.activate, + [META_KEYS.DESC_KEY]: child.descKey, + [META_KEYS.I18N_KEY]: child.i18nKey, + [META_KEYS.BREADCRUMB]: true, + }, + component: Layer, + }; + + route.children!.push(childRoute); + + // Add tab route for active tab index + const tabRoute: AppRouteRecordRaw = { + name: `${child.name}ActiveTabIndex`, + path: `/${child.path}/tab/:activeTabIndex`, + component: Layer, + meta: { + [META_KEYS.NOT_SHOW]: true, + [META_KEYS.LAYER]: child.layer, + [META_KEYS.TITLE]: child.title, + [META_KEYS.BREADCRUMB]: false, + }, + }; + + route.children!.push(tabRoute); + } + } else { + // Handle non-grouped items route.children = [ { name: item.name, - path: item.path, + path: item.path || "", meta: { - title: item.title, - layer: item.layer, - icon: item.icon, - activate: item.activate, - descKey: item.descKey, - i18nKey: item.i18nKey, + [META_KEYS.TITLE]: item.title, + [META_KEYS.LAYER]: item.layer, + [META_KEYS.ICON]: item.icon, + [META_KEYS.ACTIVATE]: item.activate, + [META_KEYS.DESC_KEY]: item.descKey, + [META_KEYS.I18N_KEY]: item.i18nKey, + [META_KEYS.BREADCRUMB]: true, }, component: Layer, }, ]; } + return route; }); - return routes; } -export default layerDashboards(); +export default generateLayerDashboards(); diff --git a/src/router/marketplace.ts b/src/router/marketplace.ts index a2b61154..e7e5307e 100644 --- a/src/router/marketplace.ts +++ b/src/router/marketplace.ts @@ -14,27 +14,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; +import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants"; import Layout from "@/layout/Index.vue"; import Marketplace from "@/views/Marketplace.vue"; -export const routesMarketplace: Array<RouteRecordRaw> = [ +export const routesMarketplace: AppRouteRecordRaw[] = [ { path: "", - name: "Marketplace", + name: ROUTE_NAMES.MARKETPLACE, meta: { - i18nKey: "marketplace", - icon: "marketplace", - hasGroup: false, - activate: true, - title: "Marketplace", + [META_KEYS.I18N_KEY]: "marketplace", + [META_KEYS.ICON]: "marketplace", + [META_KEYS.HAS_GROUP]: false, + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "Marketplace", + [META_KEYS.BREADCRUMB]: true, }, component: Layout, children: [ { - path: "/marketplace", + path: ROUTE_PATHS.MARKETPLACE, name: "MenusManagement", component: Marketplace, + meta: { + [META_KEYS.TITLE]: "Marketplace", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, diff --git a/src/router/notFound.ts b/src/router/notFound.ts index 8efe0da0..410ee0e6 100644 --- a/src/router/notFound.ts +++ b/src/router/notFound.ts @@ -14,13 +14,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; +import { ROUTE_NAMES, ROUTE_PATHS } from "./constants"; import NotFound from "@/views/NotFound.vue"; -export const routesNotFound: Array<RouteRecordRaw> = [ +export const routesNotFound: AppRouteRecordRaw[] = [ { - path: "/:pathMatch(.*)*", - name: "NotFound", + path: ROUTE_PATHS.NOT_FOUND, + name: ROUTE_NAMES.NOT_FOUND, component: NotFound, + meta: { + title: "Page Not Found", + notShow: true, + }, }, ]; diff --git a/src/router/settings.ts b/src/router/settings.ts index e6f14552..ae1dca16 100644 --- a/src/router/settings.ts +++ b/src/router/settings.ts @@ -14,27 +14,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { RouteRecordRaw } from "vue-router"; +import type { AppRouteRecordRaw } from "@/types/router"; +import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants"; import Layout from "@/layout/Index.vue"; import Settings from "@/views/Settings.vue"; -export const routesSettings: Array<RouteRecordRaw> = [ +export const routesSettings: AppRouteRecordRaw[] = [ { path: "", - name: "Settings", + name: ROUTE_NAMES.SETTINGS, meta: { - i18nKey: "settings", - icon: "settings", - hasGroup: false, - activate: true, - title: "Settings", + [META_KEYS.I18N_KEY]: "settings", + [META_KEYS.ICON]: "settings", + [META_KEYS.HAS_GROUP]: false, + [META_KEYS.ACTIVATE]: true, + [META_KEYS.TITLE]: "Settings", + [META_KEYS.BREADCRUMB]: true, }, component: Layout, children: [ { - path: "/settings", + path: ROUTE_PATHS.SETTINGS, name: "ViewSettings", component: Settings, + meta: { + [META_KEYS.TITLE]: "Settings", + [META_KEYS.BREADCRUMB]: true, + }, }, ], }, diff --git a/src/router/utils.ts b/src/router/utils.ts new file mode 100644 index 00000000..0830d9ee --- /dev/null +++ b/src/router/utils.ts @@ -0,0 +1,102 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AppRouteRecordRaw } from "@/types/router"; +import { DEFAULT_ROUTE } from "./constants"; + +/** + * Find the first activated route from a list of routes + */ +export function findActivatedRoute(routes: AppRouteRecordRaw[]): string | null { + for (const route of routes) { + if (route.children) { + for (const child of route.children) { + if (child.meta?.activate) { + return child.path; + } + } + } + } + return null; +} + +/** + * Get default route path + */ +export function getDefaultRoute(routes: AppRouteRecordRaw[]): string { + const activatedRoute = findActivatedRoute(routes); + return activatedRoute || DEFAULT_ROUTE; +} + +/** + * Check if route requires authentication + */ +export function requiresAuth(route: AppRouteRecordRaw): boolean { + return route.meta?.requiresAuth === true; +} + +/** + * Generate breadcrumb data from route + */ +export function generateBreadcrumb(route: AppRouteRecordRaw): string[] { + const breadcrumbs: string[] = []; + + if (route.meta?.title) { + breadcrumbs.push(route.meta.title); + } + + if (route.children) { + route.children.forEach((child) => { + if (child.meta?.breadcrumb !== false && child.meta?.title) { + breadcrumbs.push(child.meta.title); + } + }); + } + + return breadcrumbs; +} + +/** + * Validate route configuration + */ +export function validateRoute(route: AppRouteRecordRaw): boolean { + if (!route.path || !route.name || !route.component) { + return false; + } + + if (route.children) { + return route.children.every((child) => validateRoute(child)); + } + + return true; +} + +/** + * Flatten nested routes for easier processing + */ +export function flattenRoutes(routes: AppRouteRecordRaw[]): AppRouteRecordRaw[] { + const flattened: AppRouteRecordRaw[] = []; + + routes.forEach((route) => { + flattened.push(route); + if (route.children) { + flattened.push(...flattenRoutes(route.children)); + } + }); + + return flattened; +} diff --git a/src/router/alarm.ts b/src/types/router.ts similarity index 59% copy from src/router/alarm.ts copy to src/types/router.ts index 0249b829..2116bcfc 100644 --- a/src/router/alarm.ts +++ b/src/types/router.ts @@ -14,28 +14,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import type { RouteRecordRaw } from "vue-router"; -import Layout from "@/layout/Index.vue"; -import Alarm from "@/views/Alarm.vue"; -export const routesAlarm: Array<RouteRecordRaw> = [ - { - path: "", - name: "Alarm", - meta: { - i18nKey: "alarm", - icon: "spam", - hasGroup: false, - activate: true, - title: "Alerting", - }, - component: Layout, - children: [ - { - path: "/alerting", - name: "ViewAlarm", - component: Alarm, - }, - ], - }, -]; +export interface RouteMeta { + title?: string; + i18nKey?: string; + icon?: string; + hasGroup?: boolean; + activate?: boolean; + descKey?: string; + layer?: string; + notShow?: boolean; + requiresAuth?: boolean; + breadcrumb?: boolean; +} + +export interface AppRouteRecordRaw extends Omit<RouteRecordRaw, "meta" | "children"> { + meta: RouteMeta; + children?: AppRouteRecordRaw[]; +} + +export interface RouteConfig { + path: string; + name: string; + component: any; + meta: RouteMeta; + children?: RouteConfig[]; +} + +export interface NavigationGuard { + to: any; + from: any; + next: any; +}