Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
3 changes: 2 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"enabled": true,
"environment": "all",
"path": ".angular/cache"
}
},
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
Expand Down
2,257 changes: 385 additions & 1,872 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion scripts/nav-ids.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@
"accounting.provisioning-entries",
"accounting.rules",
"accounting.run-accruals",
"accounts",
"admin",
"admin.batch-operations",
"admin.cob-tools",
"admin.external-events",
"admin.inline-job",
"admin.progressive-loan",
"admin.wc-cob-tools",
"administration",
"campaigns",
"campaigns.email",
"campaigns.email-messages",
"campaigns.sms",
"centers",
"client-services",
"clients",
"clients.search",
"collection-sheet",
"dashboard",
"finance",
"fintech",
"fintech.asset-owners",
"groups",
Expand All @@ -38,13 +42,15 @@
"interop.parties",
"interop.quotes",
"interop.transfers",
"lending",
"loans",
"loans.account-locks",
"loans.bulk-reassignment",
"loans.cob-catchup",
"loans.point-in-time",
"loans.schedule-modify",
"notifications",
"operations",
"organization",
"organization.account-number-formats",
"organization.currencies",
Expand All @@ -56,6 +62,7 @@
"organization.payment-types",
"organization.staff",
"products",
"products-and-accounts",
"products.collateral-management",
"products.fixed",
"products.fixed-deposits",
Expand Down Expand Up @@ -135,6 +142,7 @@
"working-capital.loan-products",
"working-capital.loans",
"working-capital.near-breach",
"working-capital.wc-cob-tools"
"working-capital.wc-cob-tools",
"workspace"
]
}
44 changes: 44 additions & 0 deletions src/app/core/services/navigation-config.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ describe('NavigationConfigService', () => {
items.some(
(item) => item.route === route || (item.children && findRoute(item.children, route)),
);
const findItem = (items: readonly NavItemConfig[], id: string): NavItemConfig | undefined => {
for (const item of items) {
if (item.id === id) return item;
const nested = item.children ? findItem(item.children, id) : undefined;
if (nested) return nested;
}
return undefined;
};

const COB_TOOLS_ROUTE = '/admin/cob-tools';
const PLACE_LOCK_ROUTE = '/working-capital/loans/account-locks';
Expand Down Expand Up @@ -208,6 +216,42 @@ describe('NavigationConfigService', () => {
expect(service.navConfig.length).toBeGreaterThan(0);
});

it('organizes all destinations into clear work areas without changing their routes', () => {
expect(service.navConfig.map((item) => item.id)).toEqual([
'dashboard',
'workspace',
'client-services',
'lending',
'products-and-accounts',
'operations',
'finance',
'administration',
]);
expect(findRoute(service.navConfig, '/notifications')).toBe(true);
expect(findRoute(service.navConfig, '/clients')).toBe(true);
expect(findRoute(service.navConfig, '/loans')).toBe(true);
expect(findRoute(service.navConfig, '/working-capital/loans')).toBe(true);
expect(findRoute(service.navConfig, '/products/loan')).toBe(true);
expect(findRoute(service.navConfig, '/products/savings-accounts')).toBe(true);
expect(findRoute(service.navConfig, '/transfers/history')).toBe(true);
expect(findRoute(service.navConfig, '/accounting/journal-entries')).toBe(true);
expect(findRoute(service.navConfig, '/system/data-tables')).toBe(true);
});

it('separates account destinations from product setup while retaining stable item ids', () => {
const catalog = findItem(service.navConfig, 'products');
const accounts = findItem(service.navConfig, 'accounts');

expect(catalog?.children?.some((item) => item.id === 'products.loan')).toBe(true);
expect(catalog?.children?.some((item) => item.id === 'products.savings-accounts')).toBe(false);
expect(accounts?.children?.map((item) => item.id)).toEqual([
'products.savings-accounts',
'products.fixed-deposits',
'products.recurring-deposits',
'products.shares',
]);
});

it('shows everything when rbacEnabled is false, regardless of permissions', () => {
TestBed.resetTestingModule();
configure({ rbacEnabled: false });
Expand Down
118 changes: 117 additions & 1 deletion src/app/core/services/navigation-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function flattenNavRoutes(
* template. Permission and feature-flag gates match the ones already applied
* to `sidebar.component.ts` — no new gates are introduced here.
*/
const NAV_CONFIG: readonly NavItemConfig[] = [
const NAV_CONFIG_BASE: readonly NavItemConfig[] = [
{ id: 'dashboard', route: '/dashboard', labelKey: 'nav.dashboard', icon: ICON_GRID_OUTLINE },
{
id: 'notifications',
Expand Down Expand Up @@ -1060,6 +1060,122 @@ const NAV_CONFIG: readonly NavItemConfig[] = [
},
];

/**
* Regroups the original destinations into a short set of recognizable work areas.
* Existing destination and group ids are retained so permission/config overrides continue to
* address the same entries; this function only changes their presentation hierarchy.
*/
function organizeNavigation(base: readonly NavItemConfig[]): NavItemConfig[] {
const remaining = [...base];
const take = (id: string): NavItemConfig | undefined => {
const index = remaining.findIndex((item) => item.id === id);
return index === -1 ? undefined : remaining.splice(index, 1)[0];
};
const takeMany = (ids: readonly string[]): NavItemConfig[] =>
ids.map((id) => take(id)).filter((item): item is NavItemConfig => item !== undefined);
const section = (id: string, icon: string): NavItemConfig | undefined => {
const item = take(id);
return item ? { ...item, icon } : undefined;
};
const group = (
config: Pick<NavItemConfig, 'id' | 'labelKey' | 'icon'>,
children: NavItemConfig[],
): NavItemConfig => ({ ...config, children });

const dashboard = take('dashboard');
const workspace = group(
{ id: 'workspace', labelKey: 'nav.workspace', icon: ICON_GRID_OUTLINE },
takeMany(['notifications', 'search', 'profile']),
);
const clientServices = group(
{ id: 'client-services', labelKey: 'nav.clientServices', icon: ICON_PEOPLE_OUTLINE },
takeMany(['clients', 'groups', 'centers', 'collection-sheet']),
);
const lending = group(
{ id: 'lending', labelKey: 'nav.lending', icon: 'cash-outline' },
takeMany([
'loans',
'loans.bulk-reassignment',
'loans.point-in-time',
'loans.account-locks',
'loans.cob-catchup',
'loans.schedule-modify',
]).concat(section('working-capital', ICON_BUSINESS_OUTLINE) ?? []),
);

const products = take('products');
const accountIds = new Set([
'products.savings-accounts',
'products.fixed-deposits',
'products.recurring-deposits',
'products.shares',
]);
const accountItems = (products?.children ?? []).filter(
(item) => item.id !== undefined && accountIds.has(item.id),
);
const productItems = (products?.children ?? []).filter(
(item) => item.id === undefined || !accountIds.has(item.id),
);
const productSections = products
? [{ ...products, icon: ICON_WALLET_OUTLINE, children: productItems }]
: [];
const accountSection = accountItems.length
? [group({ id: 'accounts', labelKey: 'nav.accounts', icon: ICON_WALLET_OUTLINE }, accountItems)]
: [];
const productsAndAccounts = group(
{
id: 'products-and-accounts',
labelKey: 'nav.productsAndAccounts',
icon: ICON_WALLET_OUTLINE,
},
[...productSections, ...accountSection],
);

const operations = group(
{ id: 'operations', labelKey: 'nav.operations', icon: ICON_SWAP_HORIZONTAL_OUTLINE },
[
section('transfers', ICON_SWAP_HORIZONTAL_OUTLINE),
section('teller-operations', 'storefront-outline'),
section('tasks', 'checkmark-done-outline'),
section('campaigns', 'megaphone-outline'),
section('interop', ICON_GIT_NETWORK_OUTLINE),
section('fintech', 'shield-checkmark-outline'),
section('spm', 'bar-chart-outline'),
].filter((item): item is NavItemConfig => item !== undefined),
);
const finance = group(
{ id: 'finance', labelKey: 'nav.finance', icon: ICON_CALCULATOR_OUTLINE },
[
section('accounting', ICON_CALCULATOR_OUTLINE),
section('reporting', 'bar-chart-outline'),
].filter((item): item is NavItemConfig => item !== undefined),
);
const administration = group(
{ id: 'administration', labelKey: 'nav.administration', icon: 'settings-outline' },
[
section('organization', ICON_BUSINESS_OUTLINE),
section('admin', ICON_OPTIONS_OUTLINE),
section('security', ICON_LOCK_CLOSED_OUTLINE),
section('settings', 'settings-outline'),
section('system', ICON_GRID_OUTLINE),
].filter((item): item is NavItemConfig => item !== undefined),
);

return [
...(dashboard ? [dashboard] : []),
workspace,
clientServices,
lending,
productsAndAccounts,
operations,
finance,
administration,
...remaining,
];
}

const NAV_CONFIG = organizeNavigation(NAV_CONFIG_BASE);

/**
* Recursively filters a navigation tree using the given visibility predicate.
* Dividers always pass through. A group whose children are all filtered out
Expand Down
85 changes: 81 additions & 4 deletions src/app/layout/sidebar.component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@
* under the License.
*/

import { WritableSignal, signal } from '@angular/core';
import { Component, WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { SidebarComponent } from './sidebar.component';
import { SidebarService } from '../core/services/sidebar.service';
import { ViewportService } from '../core/services/viewport.service';
import { AuthService } from '../core/services/auth.service';
import { TranslateModule } from '@ngx-translate/core';

Check failure on line 28 in src/app/layout/sidebar.component.test.ts

View workflow job for this annotation

GitHub Actions / TS/ESLint

'@ngx-translate/core' import is restricted from being used by a pattern. Use the I18N adapter (or the | appTranslate pipe) from 'app/core/adapters'. See DOCS/adr/0003-adapter-boundary.md
import { Router, RouterModule } from '@angular/router';
import { provideTranslateTesting } from '../testing/i18n-testing';

@Component({ standalone: true, template: '' })
class SidebarNavigationTestRoute {}

describe('SidebarComponent', () => {
let component: SidebarComponent;
let fixture: ComponentFixture<SidebarComponent>;
Expand All @@ -44,7 +49,11 @@
isMobile = signal(false);

await TestBed.configureTestingModule({
imports: [RouterModule.forRoot([]), SidebarComponent],
imports: [
TranslateModule.forRoot(),
RouterModule.forRoot([{ path: '**', component: SidebarNavigationTestRoute }]),
SidebarComponent,
],
providers: [
...provideTranslateTesting(),
provideHttpClient(),
Expand All @@ -56,6 +65,16 @@
fixture = TestBed.createComponent(SidebarComponent);
component = fixture.componentInstance;
sidebarService = TestBed.inject(SidebarService);
const auth = TestBed.inject(AuthService);
(auth as unknown as { setSession: (session: object) => void }).setSession({
username: 'test-user',
userId: 1,
base64EncodedAuthenticationKey: 'dGVzdDp0ZXN0',
authenticated: true,
officeId: 1,
officeName: 'Head Office',
permissions: ['ALL_FUNCTIONS'],
});
fixture.detectChanges();
});

Expand Down Expand Up @@ -90,6 +109,64 @@
it('renders no close button', () => {
expect(panel().querySelector('.drawer-close')).toBeNull();
});

it('keeps group expansion independent across navigation groups', () => {
const groupButtons = Array.from(
panel().querySelectorAll<HTMLButtonElement>('.nav-group-button'),
);

expect(groupButtons.length).toBeGreaterThan(1);
const [firstGroup, secondGroup] = groupButtons;

expect(firstGroup.getAttribute('aria-expanded')).toBe('true');
expect(secondGroup.getAttribute('aria-expanded')).toBe('true');
expect(firstGroup.getAttribute('aria-controls')).toBeTruthy();
expect(panel().querySelector(`#${firstGroup.getAttribute('aria-controls')}`)).not.toBeNull();
expect(firstGroup.querySelector('.nav-group-toggle')?.classList.contains('expanded')).toBe(
true,
);

firstGroup.click();
fixture.detectChanges();

expect(firstGroup.getAttribute('aria-expanded')).toBe('false');
expect(secondGroup.getAttribute('aria-expanded')).toBe('true');
const controlledList = panel().querySelector<HTMLElement>(
`#${firstGroup.getAttribute('aria-controls')}`,
);
expect(controlledList?.hidden).toBe(true);
expect(controlledList?.querySelector('.nav-item')).toBeNull();
expect(firstGroup.querySelector('.nav-group-toggle')?.classList.contains('expanded')).toBe(
false,
);
});

it('automatically expands all ancestor sections containing the active route', async () => {
await TestBed.inject(Router).navigateByUrl('/security/users');
fixture.detectChanges();

const administration = panel().querySelector<HTMLButtonElement>(
'[aria-controls="nav-group-administration"]',
);
const security = panel().querySelector<HTMLButtonElement>(
'[aria-controls="nav-group-security"]',
);
expect(administration?.getAttribute('aria-expanded')).toBe('true');
expect(security?.getAttribute('aria-expanded')).toBe('true');
expect(TestBed.inject(Router).url).toBe('/security/users');
expect(panel().querySelectorAll('.nav-group-button.active').length).toBeGreaterThan(0);
});

it('keeps every destination labeled and reachable in compact desktop mode', () => {
sidebarService.toggle();
fixture.detectChanges();

expect(panel().classList.contains('collapsed')).toBe(true);
const links = Array.from(panel().querySelectorAll<HTMLAnchorElement>('.nav-item'));
expect(links.length).toBeGreaterThan(10);
expect(links.every((link) => link.getAttribute('aria-label'))).toBe(true);
expect(links.every((link) => link.getAttribute('title'))).toBe(true);
});
});

describe('on a narrow viewport', () => {
Expand Down Expand Up @@ -151,8 +228,8 @@
fixture.detectChanges();
expect(sidebarService.isDrawerOpen()).toBe(true);

// RouterModule.forRoot([]) has no routes configured, so only the always-matching root
// path can be navigated to here without the router itself rejecting it.
// The test wildcard route lets us exercise a completed navigation without declaring
// product routes in the sidebar's isolated unit-test module.
await TestBed.inject(Router).navigateByUrl('/');
fixture.detectChanges();

Expand Down
Loading
Loading