Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App navigation refactor #12084

Merged
merged 16 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
46 changes: 46 additions & 0 deletions kolibri/core/assets/src/composables/__mocks__/useNav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* `useNav` composable function mock.
*
* If default values are sufficient for tests,
* you only need call `jest.mock('<useNav file path>')`
* at the top of a test file.
*
* If you need to override some default values from some tests,
* you can import a helper function `useNavMock` that accepts
* an object with values to be overriden and use it together
* with `mockImplementation` as follows:
*
* ```
* // eslint-disable-next-line import/named
* import useNav, { useNavMock } from '<useNav file path>';
*
* jest.mock('<useNav file path>')
*
* it('test', () => {
* useNav.mockImplementation(
* () => useNavMock({ isUserLoggedIn: true })
* );
* })
* ```
*
* You can reset your mock implementation back to default values
* for other tests by calling the following in `beforeEach`:
*
* ```
* useNav.mockImplementation(() => useNavMock())
* ```
*/

const MOCK_DEFAULTS = {
navItems: [],
topBarHeight: 64,
};

export function useNavMock(overrides = {}) {
return {
...MOCK_DEFAULTS,
...overrides,
};
}

export default jest.fn(() => useNavMock());
109 changes: 109 additions & 0 deletions kolibri/core/assets/src/composables/__tests__/useNav.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { UserKinds, NavComponentSections } from 'kolibri.coreVue.vuex.constants';
import { navItems, registerNavItem } from '../useNav';

jest.mock('kolibri.lib.logging');

describe('nav component', () => {
afterEach(() => {
// Clean up the registered navItems
navItems.pop();
});
it('should not register a navItem that has no nav navItem specific properties defined', () => {
const navItem = {};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
it('should register a navItem that has a valid icon', () => {
const navItem = {
icon: 'timer',
url: 'https://example.com',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(1);
});
it('should show not register a navItem that has an invalid icon', () => {
const navItem = {
icon: 'not an icon',
url: 'https://example.com',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
it('should not register a navItem that has a non-string icon', () => {
const navItem = {
url: 'https://example.com',
icon: 0.1,
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
it('should register a navItem that has a valid url', () => {
const navItem = {
icon: 'search',
url: 'https://example.com',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(1);
});
it('should not register a navItem that has no url', () => {
const navItem = {
icon: 'search',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
it('should not register a navItem that has a non-string url', () => {
const navItem = {
icon: 'search',
url: 0.1,
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
Object.values(UserKinds).forEach(role => {
it(`should register a navItem that has a role of ${role}`, () => {
const navItem = {
icon: 'search',
url: 'https://example.com',
render() {
return '';
},
role,
};
registerNavItem(navItem);
expect(navItems).toHaveLength(1);
});
});
it('should not register a navItem that has an unrecognized role', () => {
const navItem = {
icon: 'search',
url: 'https://example.com',
role: 'bill',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
Object.values(NavComponentSections).forEach(section => {
it(`should register a navItem that has a section of ${section}`, () => {
const navItem = {
icon: 'search',
url: 'https://example.com',
render() {
return '';
},
section,
};
registerNavItem(navItem);
expect(navItems).toHaveLength(1);
});
});
it('should not register a navItem that has an unrecognized section', () => {
const navItem = {
icon: 'search',
url: 'https://example.com',
section: 'bill',
};
registerNavItem(navItem);
expect(navItems).toHaveLength(0);
});
});
89 changes: 89 additions & 0 deletions kolibri/core/assets/src/composables/useNav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import { KolibriIcons } from 'kolibri-design-system/lib/KIcon/iconDefinitions';
import { get } from '@vueuse/core';
import { UserKinds, NavComponentSections } from 'kolibri.coreVue.vuex.constants';
import logger from 'kolibri.lib.logging';
import { computed } from 'kolibri.lib.vueCompositionApi';

const logging = logger.getLogger(__filename);

export const navItems = [];

function checkDeclared(property) {
return typeof property !== 'undefined' && property !== null;
}

function validateUrl(url) {
return checkDeclared(url) && typeof url === 'string';
}

function validateIcon(icon) {
return checkDeclared(icon) && typeof icon === 'string' && Boolean(KolibriIcons[icon]);
}

function validateRole(role) {
// Optional, must be one of the defined UserKinds
return !checkDeclared(role) || Object.values(UserKinds).includes(role);
}

function validateSection(section) {
// Optional, must be one of the defined NavComponentSections
return !checkDeclared(section) || Object.values(NavComponentSections).includes(section);
}

function validateRoutes(routes) {
// Not required, if exists, must be an array of objects
// with label, route, name, and icon properties that are
// all strings.
return (
!checkDeclared(routes) ||
(Array.isArray(routes) &&
routes.every(route => {
return (
checkDeclared(route.label) &&
checkDeclared(route.route) &&
checkDeclared(route.name) &&
checkDeclared(route.icon) &&
typeof route.label === 'string' &&
typeof route.route === 'string' &&
typeof route.name === 'string' &&
typeof route.icon === 'string'
);
}))
);
}

function validateNavItem(component) {
return (
validateUrl(component.url) &&
validateIcon(component.icon) &&
validateRole(component.role) &&
validateSection(component.section) &&
validateRoutes(component.routes)
);
}

export const registerNavItem = component => {
if (!navItems.includes(component)) {
if (validateNavItem(component)) {
navItems.push(component);
} else {
logging.error('Component has invalid url, icon, role, section, or routes');
}
} else {
logging.warn('Component has already been registered');
}
};

export default function useNav() {
const { windowIsSmall } = useKResponsiveWindow();
const topBarHeight = computed(() => (get(windowIsSmall) ? 56 : 64));
const exportedItems = navItems.map(component => ({
...component,
active: window.location.pathname == component.url,
}));
return {
navItems: exportedItems,
topBarHeight,
};
}
16 changes: 2 additions & 14 deletions kolibri/core/assets/src/core-app/apiSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,7 @@ import ContentIcon from '../views/ContentIcon';
import ProgressIcon from '../views/ProgressIcon';
import PermissionsIcon from '../views/PermissionsIcon';
import AppBarPage from '../views/CorePage/AppBarPage';
import AppBar from '../views/AppBar';
import ImmersivePage from '../views/CorePage/ImmersivePage';
import ScrollingHeader from '../views/ScrollingHeader';
import SideNav from '../views/SideNav';
import Navbar from '../views/Navbar';
import NavbarLink from '../views/Navbar/NavbarLink';
import HorizontalNavBarWithOverflowMenu from '../views/HorizontalNavBarWithOverflowMenu';
import CoreLogo from '../views/CoreLogo';
import LanguageSwitcherList from '../views/language-switcher/LanguageSwitcherList';
import LanguageSwitcherModal from '../views/language-switcher/LanguageSwitcherModal';
Expand Down Expand Up @@ -100,6 +94,7 @@ import NotificationsRoot from '../views/NotificationsRoot';
import useMinimumKolibriVersion from '../composables/useMinimumKolibriVersion';
import useUserSyncStatus from '../composables/useUserSyncStatus';
import useUser from '../composables/useUser';
import { registerNavItem } from '../composables/useNav';

// webpack optimization
import CoreInfoIcon from '../views/CoreInfoIcon';
Expand All @@ -113,7 +108,6 @@ import SuggestedTime from '../views/SuggestedTime';

import MultiPaneLayout from '../views/MultiPaneLayout';
import filterUsersByNames from '../utils/filterUsersByNames';
import navComponents from '../utils/navComponents';
import loginComponents from '../utils/loginComponents';
import coreBannerContent from '../utils/coreBannerContent';
import CatchErrors from '../utils/CatchErrors';
Expand Down Expand Up @@ -144,21 +138,15 @@ export default {
mappers,
},
components: {
ScrollingHeader,
Backdrop,
CoachContentLabel,
DownloadButton,
ProgressBar,
ContentIcon,
ProgressIcon,
PermissionsIcon,
AppBar,
AppBarPage,
ImmersivePage,
SideNav,
Navbar,
NavbarLink,
HorizontalNavBarWithOverflowMenu,
LanguageSwitcherModal,
LanguageSwitcherList,
ElapsedTime,
Expand Down Expand Up @@ -238,7 +226,7 @@ export default {
i18n,
licenseTranslations,
loginComponents,
navComponents,
registerNavItem,
redirectBrowser,
samePageCheckGenerator,
serverClock,
Expand Down
44 changes: 0 additions & 44 deletions kolibri/core/assets/src/mixins/nav-components.js

This file was deleted.