Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
18 changes: 13 additions & 5 deletions src/api/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@
* This file contains client-side API functions that call our express.js backend routes
*/

export const getMessage = async () => {
export const getPost = async (postId) => {
try {
const response = await fetch('/api/message');
const data = await response.json();
return data.message;
const response = await fetch(`/api/post/${postId}`);
return await response.json();
} catch (error) {
throw new Error('Failed to load message: ', error);
throw new Error('Failed to load post: ', error);
}
};

export const getComments = async (postId) => {
try {
const response = await fetch(`/api/comments?postId=${postId}`);
return await response.json();
} catch (error) {
throw new Error('Failed to load comments: ', error);
}
};
16 changes: 14 additions & 2 deletions src/components/nav/NavHeaderItems.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { HeaderMenu, HeaderMenuItem } from '@carbon/react';
import { Link as RouterLink } from 'react-router';

/**
* Check if a menu path should be active based on the current path
* Handles both exact matches and dynamic route segments
*/
const isPathActive = (menuPath, currentPath) => {
if (!menuPath || !currentPath) return false;
// Exact match
if (menuPath === currentPath) return true;
// Match dynamic routes: /dashboard should be active for /dashboard/123
return currentPath.startsWith(`${menuPath}/`);
};

export const NavHeaderItems = ({ routesInHeader, currentPath }) => (
<>
{routesInHeader.map(({ path, carbon }) =>
Expand All @@ -16,7 +28,7 @@ export const NavHeaderItems = ({ routesInHeader, currentPath }) => (
as={RouterLink}
to={subRoute.path}
key={subRoute.path}
isActive={subRoute.path === currentPath}
isActive={isPathActive(subRoute.path, currentPath)}
>
{subRoute.carbon.label}
</HeaderMenuItem>
Expand All @@ -27,7 +39,7 @@ export const NavHeaderItems = ({ routesInHeader, currentPath }) => (
as={RouterLink}
key={path}
to={path}
isActive={path === currentPath}
isActive={isPathActive(path, currentPath)}
>
{carbon?.label}
</HeaderMenuItem>
Expand Down
11 changes: 11 additions & 0 deletions src/config/server-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copyright IBM Corp. 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

// Server configuration constants
// Extracted to avoid importing the full server during tests
export const port = process.env.PORT || 5173;
export const base = process.env.BASE || 'http://localhost';
52 changes: 51 additions & 1 deletion src/pages/dashboard/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* LICENSE file in the root directory of this source tree.
*/

import { Column, Grid, Tile } from '@carbon/react';
import { Column, Grid, Link, Tile, Stack, Tag } from '@carbon/react';
import { useState } from 'react';
import { useParams, useSearchParams } from 'react-router';

import { Footer } from '../../components/footer/Footer';
import { PageLayout } from '../../layouts/page-layout';
Expand All @@ -31,6 +32,15 @@ const NumberTile = () => {
};

const Dashboard = () => {
// Access path parameters (e.g., /dashboard/1234 -> id = "1234")
const params = useParams();
const { id } = params;

// Access query parameters (e.g., /dashboard/1234?q=xxx&name=John -> q = "xxx", name = "John")
const [searchParams] = useSearchParams();
const queryParam = searchParams.get('q');
const nameParam = searchParams.get('name');

return (
<PageLayout
className="cs--dashboard"
Expand All @@ -40,6 +50,46 @@ const Dashboard = () => {
<PageHeader title="Dashboard" />
</PageLayout.Header>
<Grid>
{/* Example: Display URL parameters when present */}
<Column sm={4} md={8} lg={16}>
<Tile className="cs--dashboard__tile">
<Stack gap={5}>
<strong>URL Parameters Example</strong>
{nameParam && (
<h2 style={{ margin: 0 }}>Hello {nameParam}! 👋</h2>
)}
<p>
This demonstrates how to access both path parameters and query
parameters from the URL. <br />
Try accessing:{' '}
<Link href="/dashboard/1234?q=xyz&name=Anne">
/dashboard/1234?q=xyz&name=Anne
</Link>
</p>
<Stack gap={3}>
{id && (
<div>
<strong>Path Parameter (id):</strong>{' '}
<Tag type="blue">{id}</Tag>
</div>
)}
{queryParam && (
<div>
<strong>Query Parameter (q):</strong>{' '}
<Tag type="green">{queryParam}</Tag>
</div>
)}
{nameParam && (
<div>
<strong>Query Parameter (name):</strong>{' '}
<Tag type="purple">{nameParam}</Tag>
</div>
)}
</Stack>
</Stack>
</Tile>
</Column>

<NumberTile />
<NumberTile />
<NumberTile />
Expand Down
62 changes: 37 additions & 25 deletions src/pages/welcome/Welcome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,23 @@ import {
Grid,
Heading,
Tile,
UnorderedList,
ListItem,
Section,
Stack,
} from '@carbon/react';
import { useEffect, useState } from 'react';
import { Suspense } from 'react';

import { getMessage } from '../../api/message.js';
import { Footer } from '../../components/footer/Footer';
import { WelcomeHeader } from './WelcomeHeader.jsx';
import { PageLayout } from '../../layouts/page-layout.jsx';
import PostComponent from './post/PostComponent.jsx';

// The styles are imported into index.scss by default.
// Do the same unless you have a good reason not to.
// import './welcome.scss';

const Welcome = () => {
const [message, setMessage] = useState('');

useEffect(() => {
const loadMessage = async () => {
try {
const msg = await getMessage();
setMessage(msg);
} catch {
setMessage('Failed to load message');
}
};

loadMessage();
}, []);

return (
<PageLayout
className="cs--welcome"
Expand Down Expand Up @@ -154,14 +142,38 @@ const Welcome = () => {
lg={12}
className="cs--welcome__dynamic-message"
>
<p>
Below is a dynamically fetched message from an external API
endpoint. This showcases how to perform data fetching while
keeping components clean and separating network logic.
</p>
<Tile>
<strong>Message:</strong> {message || 'Loading...'}
</Tile>
<Stack gap={3}>
<p>
Below is a dynamically fetched message from an external API
endpoint. This showcases how to perform data fetching while
keeping components clean and separating network logic. Here is
how it works:
</p>
<UnorderedList>
<ListItem>
<strong>UI Layer</strong> - PostComponent.jsx manages React
state and renders the data using Carbon Design components
</ListItem>
<ListItem>
<strong>API Layer</strong> - Client-side functions in{' '}
<code>api/message.js</code> handle HTTP requests to our
Express backend
</ListItem>
<ListItem>
<strong>Service Layer</strong> - Server-side handlers in{' '}
<code>service/postHandlers.js</code> proxy requests to
external APIs.
</ListItem>
</UnorderedList>
<p>
This pattern keeps your components focused on presentation
while centralizing data fetching logic for reusability and
testability.
</p>
</Stack>
<Suspense>
<PostComponent />
</Suspense>
</Column>
</Grid>
</Column>
Expand Down
71 changes: 71 additions & 0 deletions src/pages/welcome/post/PostComponent.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getComments, getPost } from '../../../api/message.js';
import { Heading, Section, Tile, Stack, Layer } from '@carbon/react';
import { useEffect, useState } from 'react';

const PostComponent = () => {
const [post, setPost] = useState();
const [comments, setComments] = useState([]);

const loadPost = async () => {
try {
const post = await getPost(1);
setPost(post);
} catch {
setPost('Failed to load message');
}
};

const loadComments = async () => {
try {
const comments = await getComments(1);
setComments(comments);
} catch {
setComments([]);
}
};

useEffect(() => {
const loadData = async () => {
await loadPost();
await loadComments();
};
loadData();
}, []);

return (
<Section as="article" level={3}>
<Heading>Posts</Heading>
<Tile>
<Stack gap={6}>
<Section as="article" level={3}>
<Section level={4}>
<Heading>{post?.title ?? 'Loading...'}</Heading>
<p>{post?.body}</p>
</Section>
</Section>

<Section as="article" level={5}>
<Stack gap={3}>
<Heading>Comments</Heading>
<Section as="article" level={6}>
<Stack gap={3}>
{Array.isArray(comments) &&
comments.map((comment) => (
<Layer key={comment.id}>
<Tile title={`Post from ${comment.email}`}>
<Heading>{`From ${comment.email}`}</Heading>
<p>{comment.body}</p>
</Tile>
</Layer>
))}
</Stack>
</Section>
</Stack>
</Section>
</Stack>
</Tile>
</Section>
);
};

export default PostComponent;
7 changes: 7 additions & 0 deletions src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export const routes = [
inHeader: true,
},
},
{
path: '/dashboard/:id',
element: Dashboard,
},
{
path: '/link-1',
element: Placeholder,
Expand Down Expand Up @@ -154,6 +158,9 @@ const routesProcessed = routes.map((route) => {
const path = route.path || route.carbon.virtualPath;

const subMenu = routes.filter((subRoute) => {
// Only include routes with carbon config in navigation menus
if (!subRoute.carbon) return false;

const subPath = subRoute.path || subRoute.carbon.virtualPath;
const childPath = new RegExp(`^${path}/[^/]+$`); // match direct parent only

Expand Down
26 changes: 18 additions & 8 deletions src/routes/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import { getMessage } from '../service/message.js';

export const routeHandlers = {
getMessage,
};
import { getPost, getComments } from '../service/postHandlers.js';
import {
getExternalPost,
getExternalComments,
} from '../service/externalHandlers.js';

/**
* Registers all API routes on the given Express app instance.
Expand All @@ -18,7 +17,18 @@ export const routeHandlers = {
* and allows swapping out route handlers for mocks if needed.
* @param app - Express app instance OR msw router in case of unit testing
* @param handlers - Route handlers (can be mocked for testing)
* @param externalHandlers - External API mock handlers (can be mocked for testing)
*/
export const getRoutes = (app, handlers = routeHandlers) => {
app.get('/api/message', handlers.getMessage);
export const getRoutes = (
app,
handlers = { getPost, getComments },
externalHandlers = { getExternalPost, getExternalComments },
) => {
// Client-facing API routes (these call the external routes below)
app.get('/api/post/:id', handlers.getPost);
app.get('/api/comments', handlers.getComments);

// Mock "external" API routes (simulate external services)
app.get('/api/external/post/:id', externalHandlers.getExternalPost);
app.get('/api/external/comments', externalHandlers.getExternalComments);
};
5 changes: 2 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import fs from 'node:fs/promises';
import express from 'express';
import { Transform } from 'node:stream';
import { getRoutes } from './routes/routes.js';
import { port, base } from './config/server-config.js';

// Constants
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const base = process.env.BASE || '/';
const ABORT_DELAY = 10000;

// Create http server
Expand Down Expand Up @@ -109,5 +108,5 @@ app.use('*all', async (req, res) => {

// Start http server
app.listen(port, () => {
console.log(`Server started at http://localhost:${port}`);
console.log(`Server started at: ${base}:${port}`);
});
Loading
Loading