From 00d1ff3ad82c1436923fc1203f330b3e38365cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Tue, 16 Sep 2025 15:28:25 -0400 Subject: [PATCH] Add SPA utils to vue_shared The Automate section used some utilities to build a SPA and avoid having to manually define a lot of the Router/ breadcrumbs/active navigation items logic. This commit extracts this logic and move it to vue_shared while abstracting away even further to allow for true reusability. --- .../javascripts/lib/utils/breadcrumbs.js | 3 +- .../spa/components/spa_breadcrumbs.vue | 13 +- .../vue_shared/spa/components/spa_root.vue | 18 ++ .../javascripts/vue_shared/spa/index.js | 52 ++++ .../javascripts/vue_shared/spa/utils.js | 34 +++ .../vue_shared/spa/utils}/dom_utils.js | 0 .../javascripts/vue_shared/spa/utils/index.js | 18 ++ .../ai/duo_agents_platform/index.js | 28 +- .../ai/duo_agents_platform/router/utils.js | 18 -- .../ai/duo_agents_platform/index_spec.js | 171 ++++++++++++ .../router/dom_utils_spec.js | 210 --------------- .../duo_agents_platform_breadcrumbs_spec.js | 121 --------- .../duo_agents_platform/router/utils_spec.js | 126 ++------- spec/frontend/lib/utils/breadcrumbs_spec.js | 47 ++++ .../spa/components/spa_breadcrumbs_spec.js | 210 +++++++++++++++ .../spa/components/spa_root_spec.js | 91 +++++++ spec/frontend/vue_shared/spa/index_spec.js | 255 ++++++++++++++++++ spec/frontend/vue_shared/spa/utils_spec.js | 161 +++++++++++ 18 files changed, 1093 insertions(+), 483 deletions(-) rename ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue => app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue (72%) create mode 100644 app/assets/javascripts/vue_shared/spa/components/spa_root.vue create mode 100644 app/assets/javascripts/vue_shared/spa/index.js create mode 100644 app/assets/javascripts/vue_shared/spa/utils.js rename {ee/app/assets/javascripts/ai/duo_agents_platform/router => app/assets/javascripts/vue_shared/spa/utils}/dom_utils.js (100%) create mode 100644 app/assets/javascripts/vue_shared/spa/utils/index.js create mode 100644 ee/spec/frontend/ai/duo_agents_platform/index_spec.js delete mode 100644 ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js delete mode 100644 ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js create mode 100644 spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js create mode 100644 spec/frontend/vue_shared/spa/components/spa_root_spec.js create mode 100644 spec/frontend/vue_shared/spa/index_spec.js create mode 100644 spec/frontend/vue_shared/spa/utils_spec.js diff --git a/app/assets/javascripts/lib/utils/breadcrumbs.js b/app/assets/javascripts/lib/utils/breadcrumbs.js index fd01582280e6e1..62dfe3435fa74f 100644 --- a/app/assets/javascripts/lib/utils/breadcrumbs.js +++ b/app/assets/javascripts/lib/utils/breadcrumbs.js @@ -7,6 +7,7 @@ export const injectVueAppBreadcrumbs = ( BreadcrumbsComponent, apolloProvider = null, provide = {}, + keepRootItem = false, // eslint-disable-next-line max-params ) => { const injectBreadcrumbEl = document.querySelector('#js-injected-page-breadcrumbs'); @@ -31,7 +32,7 @@ export const injectVueAppBreadcrumbs = ( props: { // The last item from the static breadcrumb set is replaced by the // root of the vue app, so the last item should be removed - staticBreadcrumbs: items.slice(0, -1), + staticBreadcrumbs: keepRootItem ? items : items.slice(0, -1), }, }); }, diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue b/app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue similarity index 72% rename from ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue rename to app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue index af7611e4d5dc28..3fa62ff9177756 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue +++ b/app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue @@ -1,7 +1,5 @@ + diff --git a/app/assets/javascripts/vue_shared/spa/index.js b/app/assets/javascripts/vue_shared/spa/index.js new file mode 100644 index 00000000000000..88ac79dbfa6632 --- /dev/null +++ b/app/assets/javascripts/vue_shared/spa/index.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; +import { activeNavigationWatcher } from './utils'; +import breadcrumbs from './components/spa_breadcrumbs.vue'; +import RootComponent from './components/spa_root.vue'; + +export const initSinglePageApplication = ({ + name = 'SinglePageAplication', + el, + router, + apolloCacheConfig = {}, + provide, + propsData, +}) => { + if (!el) { + throw new Error('You must provide a `el` prop to initVueSinglePageApplication'); + } + + if (!router) { + throw new Error('You must provide a `router` prop to initVueSinglePageApplication'); + } + + let apolloProvider; + + // To not have an apollo cache, explicitly pass null + if (apolloCacheConfig) { + Vue.use(VueApollo); + + apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(apolloCacheConfig), + }); + } + + // TODO: build dybnamically based on the router + injectVueAppBreadcrumbs(router, breadcrumbs, null, {}, true); + + router.beforeEach(activeNavigationWatcher); + + return new Vue({ + el, + name, + router, + apolloProvider, + provide, + propsData, + render(h) { + return h(RootComponent); + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/spa/utils.js b/app/assets/javascripts/vue_shared/spa/utils.js new file mode 100644 index 00000000000000..6fb9dc5545c1fe --- /dev/null +++ b/app/assets/javascripts/vue_shared/spa/utils.js @@ -0,0 +1,34 @@ +export const extractNavScopeFromRoute = (route) => { + // The first matched object is always the top level nav element + const segments = route?.matched?.[0]?.path?.split('/') || []; + return segments.length > 1 ? segments[1] : ''; +}; + +export const updateActiveNavigation = (currentScope) => { + // Find all navigation items and remove active class + const navItems = document.querySelectorAll('.nav-sidebar .nav-item'); + navItems.forEach((item) => { + item.classList.remove('active'); + }); + + // Add active class to the current scope navigation item + if (currentScope) { + const activeNavItem = document.querySelector( + `.nav-sidebar .nav-item[data-nav-scope="${currentScope}"]`, + ); + if (activeNavItem) { + activeNavItem.classList.add('active'); + } + } +}; + +export const activeNavigationWatcher = (to, from, next) => { + const currentScope = extractNavScopeFromRoute(to); + const oldScope = extractNavScopeFromRoute(from); + + if (!from?.matched.length === 0 || currentScope !== oldScope) { + updateActiveNavigation(currentScope); + } + + next(); +}; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/dom_utils.js b/app/assets/javascripts/vue_shared/spa/utils/dom_utils.js similarity index 100% rename from ee/app/assets/javascripts/ai/duo_agents_platform/router/dom_utils.js rename to app/assets/javascripts/vue_shared/spa/utils/dom_utils.js diff --git a/app/assets/javascripts/vue_shared/spa/utils/index.js b/app/assets/javascripts/vue_shared/spa/utils/index.js new file mode 100644 index 00000000000000..3edaf6f4398c83 --- /dev/null +++ b/app/assets/javascripts/vue_shared/spa/utils/index.js @@ -0,0 +1,18 @@ +import { updateActiveNavigation } from './dom_utils'; + +export const extractNavScopeFromRoute = (route) => { + // The first matched object is always the top level nav element + const segments = route?.matched?.[0]?.path?.split('/') || []; + return segments.length > 1 ? segments[1] : ''; +}; + +export const activeNavigationWatcher = (to, from, next) => { + const currentScope = extractNavScopeFromRoute(to); + const oldScope = extractNavScopeFromRoute(from); + + if (!from?.matched.length === 0 || currentScope !== oldScope) { + updateActiveNavigation(currentScope); + } + + next(); +}; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/index.js b/ee/app/assets/javascripts/ai/duo_agents_platform/index.js index f5cc9558dbf915..27c1a0ba86b41f 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/index.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/index.js @@ -1,12 +1,7 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; -import DuoAgentsPlatformBreadcrumbs from './router/duo_agents_platform_breadcrumbs.vue'; -import { activeNavigationWatcher } from './router/utils'; +import { initSinglePageApplication } from '~/vue_shared/spa'; import { createRouter } from './router'; -import DuoAgentsPlatformApp from './duo_agents_platform_app.vue'; import { getNamespaceDatasetProperties } from './utils'; +import { AGENTS_PLATFORM_INDEX_ROUTE } from './router/constants'; export const initDuoAgentsPlatformPage = ({ namespaceDatasetProperties = [], namespace }) => { if (!namespace) { @@ -39,29 +34,16 @@ export const initDuoAgentsPlatformPage = ({ namespaceDatasetProperties = [], nam } const router = createRouter(agentsPlatformBaseRoute, namespace); - router.beforeEach(activeNavigationWatcher); - Vue.use(VueApollo); - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - injectVueAppBreadcrumbs(router, DuoAgentsPlatformBreadcrumbs); - - return new Vue({ - el, - name: 'DuoAgentsPlatformApp', + return initSinglePageApplication({ router, - apolloProvider, + el, + keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE], provide: { emptyStateIllustrationPath, exploreAiCatalogPath, flowTriggersEventTypeOptions: JSON.parse(flowTriggersEventTypeOptions), ...namespaceProvideData, }, - render(h) { - return h(DuoAgentsPlatformApp); - }, }); }; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js index 6a3bb774240cb9..dc2909c1768dd7 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js @@ -1,24 +1,6 @@ import { AGENT_PLATFORM_PROJECT_PAGE, AGENT_PLATFORM_USER_PAGE } from '../constants'; import ProjectAgentsPlatformIndex from '../namespace/project/project_agents_platform_index.vue'; import userAgentsPlatformIndex from '../namespace/user/user_agents_platform_index.vue'; -import { updateActiveNavigation } from './dom_utils'; - -export const extractNavScopeFromRoute = (route) => { - // The first matched object is always the top level nav element - const segments = route?.matched?.[0]?.path?.split('/') || []; - return segments.length > 1 ? segments[1] : ''; -}; - -export const activeNavigationWatcher = (to, from, next) => { - const currentScope = extractNavScopeFromRoute(to); - const oldScope = extractNavScopeFromRoute(from); - - if (!from?.matched.length === 0 || currentScope !== oldScope) { - updateActiveNavigation(currentScope); - } - - next(); -}; export const getNamespaceIndexComponent = (namespace) => { if (!namespace) { diff --git a/ee/spec/frontend/ai/duo_agents_platform/index_spec.js b/ee/spec/frontend/ai/duo_agents_platform/index_spec.js new file mode 100644 index 00000000000000..c2d79f8e5d3511 --- /dev/null +++ b/ee/spec/frontend/ai/duo_agents_platform/index_spec.js @@ -0,0 +1,171 @@ +import { initSinglePageApplication } from '~/vue_shared/spa'; +import { initDuoAgentsPlatformPage } from 'ee/ai/duo_agents_platform'; +import { createRouter } from 'ee/ai/duo_agents_platform/router'; +import { getNamespaceDatasetProperties } from 'ee/ai/duo_agents_platform/utils'; +import { AGENTS_PLATFORM_INDEX_ROUTE } from 'ee/ai/duo_agents_platform/router/constants'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; + +// Mock GraphQL to prevent property redefinition error +jest.mock('~/lib/graphql', () => ({ + fetchPolicies: { + CACHE_AND_NETWORK: 'cache-and-network', + NETWORK_ONLY: 'network-only', + CACHE_ONLY: 'cache-only', + NO_CACHE: 'no-cache', + STANDBY: 'standby', + }, +})); +jest.mock('~/vue_shared/spa'); +jest.mock('ee/ai/duo_agents_platform/router'); +jest.mock('ee/ai/duo_agents_platform/utils'); + +describe('initDuoAgentsPlatformPage', () => { + let mockRouter; + let mockEl; + + beforeEach(() => { + setHTMLFixture('
'); + mockEl = document.getElementById('js-duo-agents-platform-page'); + mockRouter = { beforeEach: jest.fn() }; + + createRouter.mockReturnValue(mockRouter); + getNamespaceDatasetProperties.mockReturnValue({ + emptyStateIllustrationPath: '/empty-state.svg', + exploreAiCatalogPath: '/ai-catalog', + flowTriggersEventTypeOptions: '[]', + }); + initSinglePageApplication.mockReturnValue({}); + }); + + afterEach(() => { + resetHTMLFixture(); + jest.clearAllMocks(); + }); + + describe('when namespace is not provided', () => { + it('throws an error', () => { + expect(() => { + initDuoAgentsPlatformPage({ + namespaceDatasetProperties: [], + }); + }).toThrow('Namespace is required for the DuoAgentPlatform page to function'); + }); + }); + + describe('when element is not found', () => { + beforeEach(() => { + setHTMLFixture('
'); + }); + + it('returns null', () => { + const result = initDuoAgentsPlatformPage({ + namespaceDatasetProperties: [], + namespace: 'project', + }); + + expect(result).toBeNull(); + expect(initSinglePageApplication).not.toHaveBeenCalled(); + }); + }); + + describe('when required parameters are provided', () => { + const defaultParams = { + namespaceDatasetProperties: [ + 'emptyStateIllustrationPath', + 'exploreAiCatalogPath', + 'flowTriggersEventTypeOptions', + ], + namespace: 'project', + }; + + beforeEach(() => { + // Set up dataset properties on the element + mockEl.dataset.agentsPlatformBaseRoute = '/automate'; + mockEl.dataset.emptyStateIllustrationPath = '/empty-state.svg'; + mockEl.dataset.exploreAiCatalogPath = '/ai-catalog'; + mockEl.dataset.flowTriggersEventTypeOptions = '[]'; + }); + + it('creates router with correct base route and namespace', () => { + initDuoAgentsPlatformPage(defaultParams); + + expect(createRouter).toHaveBeenCalledWith('/automate', 'project'); + }); + + it('calls initSinglePageApplication with correct parameters', () => { + initDuoAgentsPlatformPage(defaultParams); + + expect(initSinglePageApplication).toHaveBeenCalledWith({ + router: mockRouter, + el: mockEl, + keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE], + provide: { + emptyStateIllustrationPath: '/empty-state.svg', + exploreAiCatalogPath: '/ai-catalog', + flowTriggersEventTypeOptions: [], + ...getNamespaceDatasetProperties.mock.results[0].value, + }, + }); + }); + + it('parses flowTriggersEventTypeOptions JSON string', () => { + mockEl.dataset.flowTriggersEventTypeOptions = '[{"value": "push", "text": "Push"}]'; + + initDuoAgentsPlatformPage(defaultParams); + + expect(initSinglePageApplication).toHaveBeenCalledWith({ + router: mockRouter, + el: mockEl, + keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE], + provide: { + emptyStateIllustrationPath: '/empty-state.svg', + exploreAiCatalogPath: '/ai-catalog', + flowTriggersEventTypeOptions: [{ value: 'push', text: 'Push' }], + ...getNamespaceDatasetProperties.mock.results[0].value, + }, + }); + }); + + it('handles invalid JSON in flowTriggersEventTypeOptions', () => { + mockEl.dataset.flowTriggersEventTypeOptions = 'invalid-json'; + + expect(() => { + initDuoAgentsPlatformPage(defaultParams); + }).toThrow(); + }); + + it('throws error when required dataset properties are missing', () => { + getNamespaceDatasetProperties.mockReturnValue({ + emptyStateIllustrationPath: '/empty-state.svg', + // Missing other required properties + }); + + expect(() => { + initDuoAgentsPlatformPage(defaultParams); + }).toThrow('One or more required properties are missing in the dataset'); + }); + }); + + describe('when agentsPlatformBaseRoute is different', () => { + beforeEach(() => { + // Mock different dataset attribute + mockEl.dataset.agentsPlatformBaseRoute = '/custom-base'; + mockEl.dataset.emptyStateIllustrationPath = '/empty-state.svg'; + mockEl.dataset.exploreAiCatalogPath = '/ai-catalog'; + mockEl.dataset.flowTriggersEventTypeOptions = '[]'; + }); + + it('uses custom base route for router creation', () => { + initDuoAgentsPlatformPage({ + namespaceDatasetProperties: [ + 'emptyStateIllustrationPath', + 'exploreAiCatalogPath', + 'flowTriggersEventTypeOptions', + ], + namespace: 'project', + }); + + expect(createRouter).toHaveBeenCalledWith('/custom-base', 'project'); + }); + }); +}); diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js deleted file mode 100644 index fe38cf0fb24468..00000000000000 --- a/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js +++ /dev/null @@ -1,210 +0,0 @@ -import { updateActiveNavigation } from 'ee/ai/duo_agents_platform/router/dom_utils'; - -// Test constants -const CSS_CLASSES = { - activeClass: 'super-sidebar-nav-item-current', - hiddenClass: 'gl-hidden', -}; - -const SELECTORS = { - superSidebar: '#super-sidebar', -}; - -// Mock factory functions -const createMockElement = (methods = {}) => ({ - classList: { - add: jest.fn(), - remove: jest.fn(), - ...methods.classList, - }, - querySelector: jest.fn(), - ...methods, -}); - -const createMockSuperSidebar = (queryResults = {}) => ({ - querySelectorAll: jest.fn().mockImplementation((selector) => { - return queryResults[selector] || []; - }), -}); - -describe('updateActiveNavigation', () => { - let mockSuperSidebar; - let mockElements; - - const setupMockSuperSidebar = (config = {}) => { - const { activeNavItems = [], newNavItems = [] } = config; - - const queryResults = { - [`.${CSS_CLASSES.activeClass}`]: activeNavItems, - }; - - // Add dynamic href-based queries - if (newNavItems.length > 0) { - // This will be set dynamically in tests - mockSuperSidebar.querySelectorAll.mockImplementation((selector) => { - if (selector.includes('[href*=')) { - return newNavItems; - } - return queryResults[selector] || []; - }); - } else { - mockSuperSidebar = createMockSuperSidebar(queryResults); - } - }; - - const setupDocumentMock = (superSidebarExists = true) => { - jest.spyOn(document, 'querySelector').mockImplementation((selector) => { - if (selector === SELECTORS.superSidebar) { - return superSidebarExists ? mockSuperSidebar : null; - } - return null; - }); - }; - - beforeEach(() => { - // Create reusable mock elements - mockElements = { - activeNavItems: [createMockElement(), createMockElement()], - }; - - mockElements.newNavItems = [createMockElement()]; - - mockSuperSidebar = createMockSuperSidebar(); - setupDocumentMock(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('when super-sidebar element exists', () => { - beforeEach(() => { - setupMockSuperSidebar({ - activeNavItems: mockElements.activeNavItems, - newNavItems: mockElements.newNavItems, - }); - }); - - it('removes active class from current active nav items', () => { - updateActiveNavigation('/test-href'); - - mockElements.activeNavItems.forEach((item) => { - expect(item.classList.remove).toHaveBeenCalledWith(CSS_CLASSES.activeClass); - }); - }); - - it('adds active class to new nav items matching href', () => { - updateActiveNavigation('/test-href'); - - expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith('[href*="/test-href"]'); - mockElements.newNavItems.forEach((item) => { - expect(item.classList.add).toHaveBeenCalledWith(CSS_CLASSES.activeClass); - }); - }); - - it('handles href with special characters', () => { - const specialHref = '/agents/test-agent-123'; - updateActiveNavigation(specialHref); - - expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith(`[href*="${specialHref}"]`); - }); - - describe('when no current active nav items exist', () => { - beforeEach(() => { - setupMockSuperSidebar({ - activeNavItems: [], - newNavItems: [mockElements.newNavItems[0]], - }); - }); - - it('does not attempt to remove classes from non-existent elements', () => { - updateActiveNavigation('/test-href'); - - mockElements.activeNavItems.forEach((item) => { - expect(item.classList.remove).not.toHaveBeenCalled(); - }); - }); - - it('still adds active class to new nav items', () => { - updateActiveNavigation('/test-href'); - - expect(mockElements.newNavItems[0].classList.add).toHaveBeenCalledWith( - CSS_CLASSES.activeClass, - ); - }); - }); - - describe('when no new nav items match the href', () => { - beforeEach(() => { - setupMockSuperSidebar({ - activeNavItems: [mockElements.activeNavItems[0]], - newNavItems: [], - }); - }); - - it('still removes current active classes', () => { - updateActiveNavigation('/non-matching-href'); - - expect(mockElements.activeNavItems[0].classList.remove).toHaveBeenCalledWith( - CSS_CLASSES.activeClass, - ); - }); - - it('does not attempt to add classes to non-existent new nav items', () => { - updateActiveNavigation('/non-matching-href'); - - mockElements.newNavItems.forEach((item) => { - expect(item.classList.add).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when new nav items exist', () => { - beforeEach(() => { - const navItems = createMockElement(); - setupMockSuperSidebar({ - activeNavItems: [], - newNavItems: [navItems], - }); - }); - - it('adds active class to nav items', () => { - expect(() => updateActiveNavigation('/test-href')).not.toThrow(); - }); - }); - }); - - describe('when super-sidebar element does not exist', () => { - beforeEach(() => { - setupDocumentMock(false); - }); - - it('returns early without throwing an error', () => { - expect(() => updateActiveNavigation('/test-href')).not.toThrow(); - }); - - it('does not attempt to query for nav items', () => { - updateActiveNavigation('/test-href'); - - expect(mockSuperSidebar.querySelectorAll).not.toHaveBeenCalled(); - }); - }); - - describe('edge cases', () => { - beforeEach(() => { - setupMockSuperSidebar({}); - }); - - const edgeCaseHrefs = [ - { value: '', description: 'empty href' }, - { value: undefined, description: 'undefined href' }, - { value: null, description: 'null href' }, - { value: '/test"path', description: 'href with quotes' }, - ]; - - it.each(edgeCaseHrefs)('handles $description', ({ value }) => { - expect(() => updateActiveNavigation(value)).not.toThrow(); - expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith(`[href*="${value}"]`); - }); - }); -}); diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js deleted file mode 100644 index fb05351f18ec15..00000000000000 --- a/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js +++ /dev/null @@ -1,121 +0,0 @@ -import { GlBreadcrumb } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import DuoAgentsPlatformBreadcrumbs from 'ee/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue'; -import { - AGENTS_PLATFORM_SHOW_ROUTE, - AGENTS_PLATFORM_NEW_ROUTE, -} from 'ee/ai/duo_agents_platform/router/constants'; - -describe('DuoAgentsPlatformBreadcrumbs', () => { - let wrapper; - - const defaultProps = { - staticBreadcrumbs: [ - { - text: 'Test Group', - to: '/groups/test-group', - }, - { - text: 'Test Project', - to: '/test-group/test-project', - }, - ], - }; - - const createWrapper = (routeOptions = { matched: [], params: {} }) => { - wrapper = shallowMount(DuoAgentsPlatformBreadcrumbs, { - propsData: { - ...defaultProps, - }, - mocks: { - $route: { - path: '/agent-sessions', - matched: routeOptions.matched, - params: { - ...routeOptions.params, - }, - }, - }, - stubs: { - GlBreadcrumb, - }, - }); - }; - - const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb); - const getBreadcrumbItems = () => findBreadcrumb().props('items'); - - describe('when component is mounted', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the breadcrumb component', () => { - expect(findBreadcrumb().exists()).toBe(true); - }); - - it('passes auto-resize as false to breadcrumb', () => { - expect(findBreadcrumb().props('autoResize')).toBe(false); - }); - }); - - describe.each` - expectedText | path | matched | params - ${'Agent sessions'} | ${'/agent-sessions'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }]} | ${{}} - ${'New'} | ${'/agent-sessions/new'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }, { path: '/agent-sessions/new', name: AGENTS_PLATFORM_NEW_ROUTE, meta: { text: 'New' }, parent: {} }]} | ${{}} - ${'4'} | ${'/agent-sessions/4'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }, { path: '/agent-sessions/:id', name: AGENTS_PLATFORM_SHOW_ROUTE, parent: {} }]} | ${{ id: 4 }} - ${'Agent sessions'} | ${'/unknown-scope/agent-sessions'} | ${[{ path: '/unknown-scope/agent-sessions', meta: { text: 'Agent sessions' } }]} | ${{}} - `('breadcrumbs generation', ({ expectedText, matched, path, params }) => { - beforeEach(() => { - createWrapper({ - matched, - params, - }); - }); - - it(`displays the correct number of breadcrumb items for ${path}`, () => { - const items = getBreadcrumbItems(); - // static routes + Automate + Agent Sessions + dynamic routes - const totalLength = 3 + matched.length; - - expect(items).toHaveLength(totalLength); - expect(items[totalLength - 1].text).toBe(expectedText); - }); - }); - - describe('when matched route has a parent', () => { - it('returns a to object with name', () => { - createWrapper({ - matched: [ - { path: '/agent-sessions', meta: { text: 'Agent sessions' } }, - { - path: '/agent-sessions/new', - name: AGENTS_PLATFORM_NEW_ROUTE, - meta: { text: 'New' }, - parent: {}, - }, - ], - params: {}, - }); - - const items = getBreadcrumbItems(); - const newRouteItem = items.find((item) => item.text === 'New'); - - expect(newRouteItem.to).toEqual({ name: AGENTS_PLATFORM_NEW_ROUTE }); - }); - }); - - describe('when matched route does not have a parent', () => { - it('returns a to object with path', () => { - createWrapper({ - matched: [{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }], - params: {}, - }); - - const items = getBreadcrumbItems(); - const agentSessionsItem = items.find((item) => item.text === 'Agent sessions'); - - expect(agentSessionsItem.to).toEqual({ path: '/agent-sessions' }); - }); - }); -}); diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js index bf545bb1b6a57c..77f7d1235d0af1 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js @@ -1,117 +1,47 @@ +import { getNamespaceIndexComponent } from 'ee/ai/duo_agents_platform/router/utils'; import { - extractNavScopeFromRoute, - activeNavigationWatcher, - getNamespaceIndexComponent, -} from 'ee/ai/duo_agents_platform/router/utils'; -import * as domUtils from 'ee/ai/duo_agents_platform/router/dom_utils'; + AGENT_PLATFORM_PROJECT_PAGE, + AGENT_PLATFORM_USER_PAGE, +} from 'ee/ai/duo_agents_platform/constants'; import ProjectAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/project/project_agents_platform_index.vue'; -import UserAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/user/user_agents_platform_index.vue'; +import userAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/user/user_agents_platform_index.vue'; -describe('extractNavScopeFromRoute', () => { - describe('when route is empty', () => { - it('returns an empty string', () => { - expect(extractNavScopeFromRoute({})).toBe(''); - }); - }); - - describe('when the route has no matched items', () => { - it('returns an empty string', () => { - expect(extractNavScopeFromRoute({ matched: [] })).toBe(''); - }); - }); - - describe('when the route has multiple matched items', () => { - it('returns the first item path without the leading slash', () => { - expect(extractNavScopeFromRoute({ matched: [{ path: '/agent-sessions/new' }] })).toBe( - 'agent-sessions', - ); - }); - }); -}); - -describe('activeNavigationWatcher', () => { - let to; - let from; - let next; - - beforeEach(() => { - jest.spyOn(domUtils, 'updateActiveNavigation').mockImplementation(() => {}); - next = jest.fn(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); +describe('Router utils', () => { + describe('getNamespaceIndexComponent', () => { + it('returns ProjectAgentsPlatformIndex for project namespace', () => { + const component = getNamespaceIndexComponent(AGENT_PLATFORM_PROJECT_PAGE); - describe('when route scopes are different', () => { - beforeEach(() => { - to = { matched: [{ path: '/agent-issues' }, { path: '/agent-issues/new' }] }; - from = { matched: [{ path: '/agent-sessions' }] }; - activeNavigationWatcher(to, from, next); + expect(component).toBe(ProjectAgentsPlatformIndex); }); - it('calls updateActiveNavigation with the correct scope', () => { - expect(domUtils.updateActiveNavigation).toHaveBeenCalledWith('agent-issues'); - }); + it('returns userAgentsPlatformIndex for user namespace', () => { + const component = getNamespaceIndexComponent(AGENT_PLATFORM_USER_PAGE); - it('calls next to continue the navigation', () => { - expect(next).toHaveBeenCalled(); + expect(component).toBe(userAgentsPlatformIndex); }); - }); - - describe('when no changes are detected in navigation scope', () => { - beforeEach(() => { - to = { matched: [{ path: '/agent-sessions' }, { path: '/agent-sessions/new' }] }; - from = { matched: [{ path: '/agent-sessions' }, { path: '/agent-sessions/existing' }] }; - activeNavigationWatcher(to, from, next); + it('throws error for undefined namespace', () => { + expect(() => { + getNamespaceIndexComponent(); + }).toThrow('The namespace argument must be passed to the Vue Router'); }); - it('does not call updateActiveNavigation', () => { - expect(domUtils.updateActiveNavigation).not.toHaveBeenCalled(); + it('throws error for null namespace', () => { + expect(() => { + getNamespaceIndexComponent(null); + }).toThrow('The namespace argument must be passed to the Vue Router'); }); - it('still calls next to continue the navigation', () => { - expect(next).toHaveBeenCalled(); - }); - }); + it('returns undefined for unknown namespace', () => { + const component = getNamespaceIndexComponent('unknown'); - describe('when there is no previous route', () => { - beforeEach(() => { - to = { matched: [{ path: '/agent-issues' }, { path: '/agent-issues/new' }] }; - from = { matched: [] }; - activeNavigationWatcher(to, from, next); + expect(component).toBeUndefined(); }); - it('calls updateActiveNavigation with the current scope', () => { - expect(domUtils.updateActiveNavigation).toHaveBeenCalledWith('agent-issues'); - }); - - it('calls next to continue the navigation', () => { - expect(next).toHaveBeenCalled(); - }); - }); -}); - -describe('getNamespaceIndexComponent', () => { - describe('when namespace is not provided', () => { - it('throws an error', () => { - expect(() => getNamespaceIndexComponent()).toThrow( - 'The namespace argument must be passed to the Vue Router', - ); + it('throws error for empty string namespace', () => { + expect(() => { + getNamespaceIndexComponent(''); + }).toThrow('The namespace argument must be passed to the Vue Router'); }); }); - - it.each` - namespace | expectedComponent | expectedComponentName - ${'project'} | ${ProjectAgentsPlatformIndex} | ${'ProjectAgentsPlatformIndex'} - ${'user'} | ${UserAgentsPlatformIndex} | ${'UserAgentsPlatformIndex'} - ${'group'} | ${undefined} | ${'undefined'} - ${'unknown'} | ${undefined} | ${'undefined'} - `( - 'returns $expectedComponentName when namespace is $namespace', - ({ namespace, expectedComponent }) => { - expect(getNamespaceIndexComponent(namespace)).toBe(expectedComponent); - }, - ); }); diff --git a/spec/frontend/lib/utils/breadcrumbs_spec.js b/spec/frontend/lib/utils/breadcrumbs_spec.js index 0d71be101ee1ef..e9eaed20e7720f 100644 --- a/spec/frontend/lib/utils/breadcrumbs_spec.js +++ b/spec/frontend/lib/utils/breadcrumbs_spec.js @@ -88,5 +88,52 @@ describe('Breadcrumbs utils', () => { const component = wrapper.findComponent(MockComponent); expect(component.props('staticBreadcrumbs')).toEqual([{ text: 'First', href: '/first' }]); }); + + describe('when keepRootItem is true', () => { + it('keeps all staticBreadcrumbs items including the last one', () => { + const breadcrumbsHTML = ` +
+ +
+
+ `; + setHTMLFixture(breadcrumbsHTML); + staticBreadcrumbs.items = [ + { text: 'First', href: '/first' }, + { text: 'Last', href: '/last' }, + ]; + const wrapper = createWrapper( + injectVueAppBreadcrumbs(mockRouter, MockComponent, mockApolloProvider, {}, true), + ); + + const component = wrapper.findComponent(MockComponent); + expect(component.props('staticBreadcrumbs')).toEqual([ + { text: 'First', href: '/first' }, + { text: 'Last', href: '/last' }, + ]); + }); + }); + + describe('when keepRootItem is false', () => { + it('removes the last item from staticBreadcrumbs', () => { + const breadcrumbsHTML = ` +
+ +
+
+ `; + setHTMLFixture(breadcrumbsHTML); + staticBreadcrumbs.items = [ + { text: 'First', href: '/first' }, + { text: 'Last', href: '/last' }, + ]; + const wrapper = createWrapper( + injectVueAppBreadcrumbs(mockRouter, MockComponent, mockApolloProvider, {}, false), + ); + + const component = wrapper.findComponent(MockComponent); + expect(component.props('staticBreadcrumbs')).toEqual([{ text: 'First', href: '/first' }]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js b/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js new file mode 100644 index 00000000000000..7feeea55f7a78d --- /dev/null +++ b/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js @@ -0,0 +1,210 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBreadcrumb } from '@gitlab/ui'; +import SpaBreadcrumbs from '~/vue_shared/spa/components/spa_breadcrumbs.vue'; + +describe('SpaBreadcrumbs', () => { + let wrapper; + + const findGlBreadcrumb = () => wrapper.findComponent(GlBreadcrumb); + + const createWrapper = (props = {}, routeData = {}) => { + const defaultProps = { + staticBreadcrumbs: [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + ], + }; + + const defaultRoute = { + params: {}, + matched: [], + }; + + wrapper = shallowMount(SpaBreadcrumbs, { + propsData: { ...defaultProps, ...props }, + mocks: { + $route: { ...defaultRoute, ...routeData }, + }, + stubs: { + GlBreadcrumb: true, + }, + }); + }; + + describe('when only static breadcrumbs are provided', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders GlBreadcrumb component', () => { + expect(findGlBreadcrumb().exists()).toBe(true); + }); + + it('passes static breadcrumbs to GlBreadcrumb', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + + it('sets auto-resize to false', () => { + expect(findGlBreadcrumb().props('autoResize')).toBe(false); + }); + }); + + describe('when route has matched routes with meta', () => { + beforeEach(() => { + createWrapper( + {}, + { + matched: [ + { + name: 'projects', + path: '/projects', + meta: { text: 'All Projects' }, + }, + { + name: 'project-detail', + path: '/projects/:id', + meta: { text: 'Project Details' }, + parent: true, + }, + ], + }, + ); + }); + + it('includes route breadcrumbs with meta text', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + { text: 'All Projects', to: { path: '/projects' } }, + { text: 'Project Details', to: { name: 'project-detail' } }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + }); + + describe('when route has matched routes without meta', () => { + beforeEach(() => { + createWrapper( + {}, + { + params: { id: '123' }, + matched: [ + { + name: 'projects', + path: '/projects', + }, + { + name: 'project-detail', + path: '/projects/:id', + parent: true, + }, + ], + }, + ); + }); + + it('uses route param id as breadcrumb text when meta is missing', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + { text: '123', to: { path: '/projects' } }, + { text: '123', to: { name: 'project-detail' } }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + }); + + describe('when route has mixed matched routes', () => { + beforeEach(() => { + createWrapper( + {}, + { + params: { id: '456' }, + matched: [ + { + name: 'projects', + path: '/projects', + meta: { text: 'All Projects' }, + }, + { + name: 'project-detail', + path: '/projects/:id', + }, + { + name: 'project-issues', + path: '/projects/:id/issues', + meta: { text: 'Issues' }, + parent: true, + }, + ], + }, + ); + }); + + it('combines static and route breadcrumbs correctly', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + { text: 'All Projects', to: { path: '/projects' } }, + { text: '456', to: { path: '/projects/:id' } }, + { text: 'Issues', to: { name: 'project-issues' } }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + }); + + describe('when route has empty matched array', () => { + beforeEach(() => { + createWrapper( + {}, + { + matched: [], + }, + ); + }); + + it('only shows static breadcrumbs', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + }); + + describe('when route has matched routes with empty meta', () => { + beforeEach(() => { + createWrapper( + {}, + { + params: {}, + matched: [ + { + name: 'projects', + path: '/projects', + meta: {}, + }, + ], + }, + ); + }); + + it('filters out routes with empty meta and no id param', () => { + const expectedCrumbs = [ + { text: 'Home', href: '/' }, + { text: 'Projects', href: '/projects' }, + ]; + + expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs); + }); + }); +}); diff --git a/spec/frontend/vue_shared/spa/components/spa_root_spec.js b/spec/frontend/vue_shared/spa/components/spa_root_spec.js new file mode 100644 index 00000000000000..5de9231161ea78 --- /dev/null +++ b/spec/frontend/vue_shared/spa/components/spa_root_spec.js @@ -0,0 +1,91 @@ +import { shallowMount } from '@vue/test-utils'; +import SpaRoot from '~/vue_shared/spa/components/spa_root.vue'; + +describe('SpaRoot', () => { + let wrapper; + + const findKeepAlive = () => wrapper.findComponent({ name: 'keep-alive' }); + const findRouterView = () => wrapper.findComponent({ name: 'router-view' }); + const findRootDiv = () => wrapper.find('#single-page-app'); + + const createWrapper = (props = {}) => { + const defaultProps = { + keepAliveComponents: [], + }; + + wrapper = shallowMount(SpaRoot, { + propsData: { ...defaultProps, ...props }, + stubs: { + 'router-view': true, + 'keep-alive': { + template: '
', + props: ['include'], + }, + }, + }); + }; + + describe('template structure', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders root div with correct id', () => { + expect(findRootDiv().exists()).toBe(true); + }); + + it('renders keep-alive component', () => { + expect(findKeepAlive().exists()).toBe(true); + }); + + it('renders router-view inside keep-alive', () => { + expect(findRouterView().exists()).toBe(true); + }); + }); + + describe('keepAliveComponents prop', () => { + describe('when keepAliveComponents is empty', () => { + beforeEach(() => { + createWrapper({ keepAliveComponents: [] }); + }); + + it('passes empty array to keep-alive include', () => { + expect(findKeepAlive().props('include')).toEqual([]); + }); + }); + + describe('when keepAliveComponents has single component', () => { + beforeEach(() => { + createWrapper({ keepAliveComponents: ['ComponentA'] }); + }); + + it('passes component name to keep-alive include', () => { + expect(findKeepAlive().props('include')).toEqual(['ComponentA']); + }); + }); + + describe('when keepAliveComponents has multiple components', () => { + beforeEach(() => { + createWrapper({ keepAliveComponents: ['ComponentA', 'ComponentB', 'ComponentC'] }); + }); + + it('passes comma-separated component names to keep-alive include', () => { + expect(findKeepAlive().props('include')).toEqual([ + 'ComponentA', + 'ComponentB', + 'ComponentC', + ]); + }); + }); + }); + + describe('prop validation', () => { + it('has correct prop definition for keepAliveComponents', () => { + const { keepAliveComponents } = SpaRoot.props; + + expect(keepAliveComponents.type).toBe(Array); + expect(keepAliveComponents.required).toBe(false); + expect(keepAliveComponents.default()).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/spa/index_spec.js b/spec/frontend/vue_shared/spa/index_spec.js new file mode 100644 index 00000000000000..2c8100af5b70f1 --- /dev/null +++ b/spec/frontend/vue_shared/spa/index_spec.js @@ -0,0 +1,255 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { shallowMount } from '@vue/test-utils'; +import { initSinglePageApplication } from '~/vue_shared/spa'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; +import { activeNavigationWatcher } from '~/vue_shared/spa/utils'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import RootComponent from '~/vue_shared/spa/components/spa_root.vue'; + +jest.mock('~/lib/utils/breadcrumbs'); +jest.mock('~/vue_shared/spa/utils'); +jest.mock('~/lib/graphql', () => jest.fn(() => ({}))); + +Vue.use(VueRouter); + +describe('initSinglePageApplication', () => { + let mockRouter; + let mockEl; + let wrapper; + + const findRootComponent = () => wrapper.find('#single-page-app'); + const findKeepAlive = () => wrapper.find('keep-alive-stub'); + const findRouterView = () => wrapper.find('router-view-stub'); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(RootComponent, { + propsData: props, + router: mockRouter, + }); + return wrapper; + }; + + describe('when required parameters are missing', () => { + beforeEach(() => { + setHTMLFixture('
'); + mockEl = document.getElementById('app'); + mockRouter = new VueRouter({ + routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }], + }); + + mockRouter.beforeEach = jest.fn(); + injectVueAppBreadcrumbs.mockReturnValue(true); + activeNavigationWatcher.mockImplementation(() => {}); + }); + + afterEach(() => { + resetHTMLFixture(); + jest.clearAllMocks(); + }); + + describe('when el is not provided', () => { + it('throws error', () => { + expect(() => { + initSinglePageApplication({ + router: mockRouter, + }); + }).toThrow('You must provide a `el` prop to initVueSinglePageApplication'); + }); + }); + + describe('when router is not provided', () => { + it('throws error', () => { + expect(() => { + initSinglePageApplication({ + el: mockEl, + }); + }).toThrow('You must provide a `router` prop to initVueSinglePageApplication'); + }); + }); + }); + + describe('when required parameters are provided', () => { + beforeEach(() => { + setHTMLFixture('
'); + mockEl = document.getElementById('app'); + mockRouter = new VueRouter({ + routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }], + }); + + mockRouter.beforeEach = jest.fn(); + injectVueAppBreadcrumbs.mockReturnValue(true); + activeNavigationWatcher.mockImplementation(() => {}); + }); + + afterEach(() => { + resetHTMLFixture(); + jest.clearAllMocks(); + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('when creating Vue instance with default name', () => { + it('creates Vue instance with default name', () => { + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + }); + + expect(app.$options.name).toBe('SinglePageAplication'); + }); + }); + + describe('when creating Vue instance with custom name', () => { + it('creates Vue instance with custom name', () => { + const app = initSinglePageApplication({ + name: 'CustomApp', + el: mockEl, + router: mockRouter, + }); + + expect(app.$options.name).toBe('CustomApp'); + }); + }); + + describe('when rendering root component', () => { + it('renders root component with keep-alive and router-view', () => { + createWrapper(); + + expect(findRootComponent().exists()).toBe(true); + expect(findKeepAlive().exists()).toBe(true); + expect(findRouterView().exists()).toBe(true); + }); + }); + + describe('when passing keepAliveComponents', () => { + it('passes keepAliveComponents to root component', () => { + const keepAliveComponents = ['ComponentA', 'ComponentB']; + + createWrapper({ keepAliveComponents }); + const keepAlive = findKeepAlive(); + + expect(keepAlive.attributes('include')).toBe(keepAliveComponents.join(',')); + }); + }); + + describe('when setting up breadcrumbs injection', () => { + it('sets up breadcrumbs injection', () => { + initSinglePageApplication({ + el: mockEl, + router: mockRouter, + }); + + expect(injectVueAppBreadcrumbs).toHaveBeenCalledWith( + mockRouter, + expect.any(Object), // breadcrumbs component + null, + {}, + true, + ); + }); + }); + + describe('when setting up router navigation watcher', () => { + it('sets up router navigation watcher', () => { + initSinglePageApplication({ + el: mockEl, + router: mockRouter, + }); + + expect(mockRouter.beforeEach).toHaveBeenCalledWith(activeNavigationWatcher); + }); + }); + + describe('when passing provide data', () => { + it('passes provide data to Vue instance', () => { + const provide = { + testData: 'test value', + anotherProp: 42, + }; + + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + provide, + }); + + expect(app.$options.provide).toEqual(provide); + }); + }); + + describe('when passing propsData', () => { + it('passes propsData to Vue instance', () => { + const propsData = { + keepAliveComponents: ['TestComponent'], + }; + + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + propsData, + }); + + expect(app.$options.propsData).toEqual(propsData); + }); + }); + }); + + describe('Apollo provider setup', () => { + beforeEach(() => { + setHTMLFixture('
'); + mockEl = document.getElementById('app'); + mockRouter = new VueRouter({ + routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }], + }); + + mockRouter.beforeEach = jest.fn(); + injectVueAppBreadcrumbs.mockReturnValue(true); + activeNavigationWatcher.mockImplementation(() => {}); + }); + + afterEach(() => { + resetHTMLFixture(); + jest.clearAllMocks(); + }); + + describe('when apolloCacheConfig is provided', () => { + it('creates Apollo provider', () => { + const apolloCacheConfig = { cache: {} }; + + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + apolloCacheConfig, + }); + + expect(app.$options.apolloProvider).toBeDefined(); + }); + }); + + describe('when apolloCacheConfig is null', () => { + it('does not create Apollo provider', () => { + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + apolloCacheConfig: null, + }); + + expect(app.$options.apolloProvider).toBeUndefined(); + }); + }); + + describe('when apolloCacheConfig is empty object', () => { + it('creates Apollo provider with default config', () => { + const app = initSinglePageApplication({ + el: mockEl, + router: mockRouter, + apolloCacheConfig: {}, + }); + + expect(app.$options.apolloProvider).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/spa/utils_spec.js b/spec/frontend/vue_shared/spa/utils_spec.js new file mode 100644 index 00000000000000..c4ac87266cc2fd --- /dev/null +++ b/spec/frontend/vue_shared/spa/utils_spec.js @@ -0,0 +1,161 @@ +import { + extractNavScopeFromRoute, + updateActiveNavigation, + activeNavigationWatcher, +} from '~/vue_shared/spa/utils'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; + +describe('SPA utils', () => { + afterEach(() => { + resetHTMLFixture(); + }); + + describe('extractNavScopeFromRoute', () => { + it('returns empty string when route is undefined', () => { + expect(extractNavScopeFromRoute()).toBe(''); + }); + + it('returns empty string when route has no matched property', () => { + const route = {}; + expect(extractNavScopeFromRoute(route)).toBe(''); + }); + + it('returns empty string when matched array is empty', () => { + const route = { matched: [] }; + expect(extractNavScopeFromRoute(route)).toBe(''); + }); + + it('returns the first segment when path has only one segment', () => { + const route = { matched: [{ path: '/dashboard' }] }; + expect(extractNavScopeFromRoute(route)).toBe('dashboard'); + }); + + it('returns the second segment when path has multiple segments', () => { + const route = { matched: [{ path: '/groups/my-group' }] }; + expect(extractNavScopeFromRoute(route)).toBe('groups'); + }); + + it('returns the second segment for deeply nested paths', () => { + const route = { matched: [{ path: '/projects/my-project/issues' }] }; + expect(extractNavScopeFromRoute(route)).toBe('projects'); + }); + }); + + describe('updateActiveNavigation', () => { + beforeEach(() => { + const navHTML = ` + + `; + setHTMLFixture(navHTML); + }); + + it('removes active class from all nav items', () => { + updateActiveNavigation('projects'); + + const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active'); + expect(activeItems).toHaveLength(1); + // Given this is for testin purposes, there are no dataset + // eslint-disable-next-line unicorn/prefer-dom-node-dataset + expect(activeItems[0].getAttribute('data-nav-scope')).toBe('projects'); + }); + + it('adds active class to the specified scope nav item', () => { + updateActiveNavigation('admin'); + + const adminItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="admin"]'); + expect(adminItem.classList.contains('active')).toBe(true); + }); + + it('does not add active class when scope is not found', () => { + updateActiveNavigation('nonexistent'); + + const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active'); + expect(activeItems).toHaveLength(0); + }); + + it('handles empty scope by removing all active classes', () => { + updateActiveNavigation(''); + + const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active'); + expect(activeItems).toHaveLength(0); + }); + }); + + describe('activeNavigationWatcher', () => { + const mockNext = jest.fn(); + + beforeEach(() => { + const navHTML = ` + + `; + setHTMLFixture(navHTML); + mockNext.mockClear(); + }); + + it('calls next function', () => { + const to = { matched: [{ path: '/projects/test' }] }; + const from = { matched: [{ path: '/groups/test' }] }; + + activeNavigationWatcher(to, from, mockNext); + + expect(mockNext).toHaveBeenCalledTimes(1); + }); + + it('updates navigation when scope changes', () => { + const to = { matched: [{ path: '/projects/test' }] }; + const from = { matched: [{ path: '/groups/test' }] }; + + activeNavigationWatcher(to, from, mockNext); + + const projectsItem = document.querySelector( + '.nav-sidebar .nav-item[data-nav-scope="projects"]', + ); + const groupsItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]'); + + expect(projectsItem.classList.contains('active')).toBe(true); + expect(groupsItem.classList.contains('active')).toBe(false); + }); + + it('does not update navigation when scope remains the same', () => { + const to = { matched: [{ path: '/projects/test1' }] }; + const from = { matched: [{ path: '/projects/test2' }] }; + + // Set initial state + document + .querySelector('.nav-sidebar .nav-item[data-nav-scope="projects"]') + .classList.add('active'); + document + .querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]') + .classList.remove('active'); + + activeNavigationWatcher(to, from, mockNext); + + const projectsItem = document.querySelector( + '.nav-sidebar .nav-item[data-nav-scope="projects"]', + ); + const groupsItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]'); + + expect(projectsItem.classList.contains('active')).toBe(true); + expect(groupsItem.classList.contains('active')).toBe(false); + }); + + it('updates navigation when from route has no matched routes', () => { + const to = { matched: [{ path: '/projects/test' }] }; + const from = { matched: [] }; + + activeNavigationWatcher(to, from, mockNext); + + const projectsItem = document.querySelector( + '.nav-sidebar .nav-item[data-nav-scope="projects"]', + ); + expect(projectsItem.classList.contains('active')).toBe(true); + }); + }); +}); -- GitLab