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

KHP3-7349-Implement Side navs and Nav groups with icons #564

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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/esm-billing-app/src/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
{
"component": "billingOverviewLink",
"name": "billing-overview-link",
"order": 0,
"slot": "billing-dashboard-link-slot"
},
{
Expand Down Expand Up @@ -189,6 +190,7 @@
{
"component": "claimsManagementOverviewDashboardLink",
"name": "claims-management-overview-link",
"order": 0,
"slot": "claims-management-dashboard-link-slot"
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ExtensionSlot, useExtensionStore } from '@openmrs/esm-framework';
import React, { useEffect } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

const HomeRoot = () => {
const baseName = window.getOpenmrsSpaBase() + 'home';

return (
<BrowserRouter basename={baseName}>
<Routes>
<Route path="/" element={<ExtensionSlot name="home-dashboard-slot" />} />
<Route path="/providers/*" element={<ExtensionSlot name="providers-dashboard-slot" />} />
<Route path="/referrals/*" element={<ExtensionSlot name="referrals-slot" />} />
<Route path="/bed-admission/*" element={<ExtensionSlot name="bed-admission-dashboard-slot" />} />
{/* Patient services Routes */}
<Route path="/appointments/*" element={<ExtensionSlot name="clinical-appointments-dashboard-slot" />} />
<Route path="/service-queues/*" element={<ExtensionSlot name="service-queues-dashboard-slot" />} />
{/* Diagnostics routes */}
<Route path="/lab-manifest/*" element={<ExtensionSlot name="lab-manifest-slot" />} />
<Route path="/laboratory/*" element={<ExtensionSlot name="laboratory-dashboard-slot" />} />
<Route path="/procedure/*" element={<ExtensionSlot name="procedure-dashboard-slot" />} />
<Route path="/imaging-orders/*" element={<ExtensionSlot name="imaging-dashboard-slot" />} />
{/* lINKAGE services Routes */}
<Route path="/pharmacy/*" element={<ExtensionSlot name="pharmacy-dashboard-slot" />} />
<Route path="/case-management/*" element={<ExtensionSlot name="case-management-dashboard-slot" />} />
<Route path="/peer-calendar/*" element={<ExtensionSlot name="peer-calendar-dashboard-slot" />} />
{/* Billing routes */}
<Route path="/billing/*" element={<ExtensionSlot name="billing-dashboard-slot" />} />
</Routes>
</BrowserRouter>
);
};

export default HomeRoot;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import DashboardGroupExtension from './dashboard-group-extension.component';
import { CarbonIconType } from '@carbon/react/icons';
import { createDashboardGroup as cdg } from '@openmrs/esm-patient-common-lib';
type Conf = {
title: string;
slotName: string;
isExpanded?: boolean;
icon?: CarbonIconType;
};

const createDashboardGroup = ({ slotName, title, isExpanded, icon }: Conf) => {
const DashboardGroup = ({ basePath }: { basePath: string }) => {
return (
<DashboardGroupExtension
title={title}
slotName={slotName}
basePath={basePath}
isExpanded={isExpanded}
icon={icon}
/>
);
};
return DashboardGroup;
};

export default createDashboardGroup;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { LinkExtension } from './link-extension.component';
import { type CarbonIconType } from '@carbon/react/icons';

type LinkConfig = {
route: string;
title: string;
otherRoutes?: Array<string>;
icon?: CarbonIconType;
};

const createLeftPanelLink = (config: LinkConfig) => {
return () => (
<BrowserRouter>
<LinkExtension route={config.route} title={config.title} otherRoutes={config.otherRoutes} icon={config.icon} />
</BrowserRouter>
);
};

export default createLeftPanelLink;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import PatientChartDasboardExtension, { DashboardExtensionProps } from './patient-chart-dashboard.component';
import { BrowserRouter } from 'react-router-dom';

const createPatientChartDashboardExtension = (props: Omit<DashboardExtensionProps, 'basePath'>) => {
return ({ basePath }: { basePath: string }) => {
return (
<BrowserRouter>
<PatientChartDasboardExtension
basePath={basePath}
title={props.title}
path={props.path}
moduleName={props.moduleName}
icon={props.icon}
/>
</BrowserRouter>
);
};
};

export default createPatientChartDashboardExtension;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Accordion, AccordionItem } from '@carbon/react';
import { CarbonIconType } from '@carbon/react/icons';
import { ExtensionSlot } from '@openmrs/esm-framework';
import { registerNavGroup } from '@openmrs/esm-patient-common-lib';
import React, { useEffect } from 'react';
import styles from './nav.scss';
type Props = {
title: string;
slotName?: string;
basePath: string;
isExpanded?: boolean;
icon?: CarbonIconType;
};
const DashboardGroupExtension: React.FC<Props> = ({ basePath, title, isExpanded, slotName, icon }) => {
useEffect(() => {
registerNavGroup(slotName);
}, [slotName]);
return (
<Accordion>
<AccordionItem
className={styles.item}
open={isExpanded ?? true}
title={
<span className={styles.itemTitle}>
{icon && React.createElement(icon)}
{title}
</span>
}
style={{ border: 'none' }}>
<ExtensionSlot name={slotName ?? title} state={{ basePath }} />
</AccordionItem>
</Accordion>
);
};

export default DashboardGroupExtension;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './link-extension.component';
export * from './utils';
export { default as createDashboardGroup } from './create-dasboard-group';
export { default as createLeftPanelLink } from './create-left-panel-link';
export { default as PatientChartDasboardExtension } from './patient-chart-dashboard.component';
export { default as createPatientChartDashboardExtension } from './create-patient-chart-dashboard-meta';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ConfigurableLink } from '@openmrs/esm-framework';
import React, { ReactNode, useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { parseParams } from './utils';
import { CarbonIconType } from '@carbon/react/icons';
import styles from './nav.scss';
export interface LinkConfig {
route: string;
title: string;
otherRoutes?: Array<string>;
icon?: CarbonIconType;
}

export const LinkExtension: React.FC<LinkConfig> = ({ route, title, otherRoutes = [], icon }) => {
const spaBasePath = window.getOpenmrsSpaBase();
const location = useLocation();
const path = useMemo(() => location.pathname.replace(spaBasePath, ''), [spaBasePath, location]);
// Parse params to see if the current route matches the location path
const matcher = useCallback(
(route: string) => {
const staticMatch = `/${route}/`.replaceAll('//', '/') === `/${path}/`.replaceAll('//', '/'); // Exact match for static routes
const paramMatch = !staticMatch && parseParams(route, path) !== null; // Check parameterized match if not exact

return staticMatch || paramMatch;
},
[path],
);
// Check if the route is active
const isActive = matcher(route);
const isOtherRoutesActive = useMemo(() => {
return otherRoutes.some(matcher);
}, [otherRoutes, matcher]);
// Generate the `to` URL for the ConfigurableLink
const to = useMemo(() => {
return (spaBasePath + route).replaceAll('//', '/');
}, [spaBasePath, route]);

return (
<ConfigurableLink
to={to}
className={`cds--side-nav__link ${isActive || isOtherRoutesActive ? 'active-left-nav-link' : ''} ${
styles.itemTitle
}`}>
{icon && React.createElement(icon)}
{title}
</ConfigurableLink>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@use '@carbon/layout';
@use '@carbon/type';

.itemTitle {
display: flex;
flex-direction: row;
gap: layout.$spacing-05;
align-items: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CarbonIconType } from '@carbon/react/icons';
import { ConfigurableLink } from '@openmrs/esm-framework';
import classNames from 'classnames';
import last from 'lodash-es/last';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import styles from './nav.scss';

export interface DashboardExtensionProps {
path: string;
title: string;
basePath: string;
moduleName?: string;
icon?: CarbonIconType;
}

const PatientChartDasboardExtension = ({
path,
title,
basePath,
moduleName = '@openmrs/esm-patient-chart-app',
icon,
}: DashboardExtensionProps) => {
const { t } = useTranslation(moduleName);
const location = useLocation();
const navLink = useMemo(() => decodeURIComponent(last(location.pathname.split('/'))), [location.pathname]);

return (
<div key={path}>
<ConfigurableLink
className={classNames('cds--side-nav__link', { 'active-left-nav-link': path === navLink }, styles.itemTitle)}
to={`${basePath}/${encodeURIComponent(path)}`}>
{icon && React.createElement(icon)}
{t(title)}
</ConfigurableLink>
</div>
);
};

export default PatientChartDasboardExtension;
42 changes: 42 additions & 0 deletions packages/esm-version-app/src/app-navigation/nav-utils/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Parses parameters from a path pattern and an actual path.
*
* @template T - A mapping of keys (parameter names) to their respective types.
* @param pathPattern - The path pattern containing parameter placeholders (e.g., `/users/:userId/roles/:roleId`).
* @param actualPath - The actual path to parse (e.g., `/users/123/roles/admin`).
* @returns An object mapping parameter names to their corresponding values, or an empty object if no match is found.
*
* @example
* const params = parseParams<{ userId: string, roleId: string }>(
* "/users/:userId/roles/:roleId",
* "/users/123/roles/admin"
* );
* console.log(params); // { userId: "123", roleId: "admin" }
*/
export function parseParams<T extends Record<string, string>>(pathPattern: string, actualPath: string): T | null {
const patternSegments = pathPattern.split('/').filter(Boolean);
const pathSegments = actualPath.split('/').filter(Boolean);

// Return null if segments do not match in length
if (patternSegments.length !== pathSegments.length) {
return null;
}

const params: Partial<T> = {};

for (let i = 0; i < patternSegments.length; i++) {
const patternSegment = patternSegments[i];
const pathSegment = pathSegments[i];

if (patternSegment.startsWith(':')) {
// Extract the parameter name (e.g., `:userId` becomes `userId`)
const paramName = patternSegment.slice(1);
(params as any)[paramName] = pathSegment;
} else if (patternSegment !== pathSegment) {
// If non-parameter segments do not match, return null
return null;
}
}

return params as T;
}
Loading
Loading