Skip to content
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
1 change: 1 addition & 0 deletions changelog.d/policyengine-shell.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add canonical PolicyEngine header, footer, and shell components for child applications.
26 changes: 26 additions & 0 deletions src/layout/PolicyEngineFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Footer, type FooterProps } from './Footer';
import {
DEFAULT_POLICYENGINE_BASE_URL,
getPolicyEngineFooterLinks,
type PolicyEngineCountryId,
} from './PolicyEngineSiteConfig';

export interface PolicyEngineFooterProps extends Omit<FooterProps, 'links'> {
country?: PolicyEngineCountryId;
baseUrl?: string;
links?: FooterProps['links'];
}

export function PolicyEngineFooter({
country = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
links,
...props
}: PolicyEngineFooterProps) {
return (
<Footer
links={links ?? getPolicyEngineFooterLinks(country, baseUrl)}
{...props}
/>
);
}
52 changes: 52 additions & 0 deletions src/layout/PolicyEngineHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';

import { Header, type CountryConfig, type HeaderProps, type NavItemConfig } from './header';
import {
DEFAULT_POLICYENGINE_BASE_URL,
getPolicyEngineCountryUrl,
getPolicyEngineNavItems,
policyEngineCountries,
type PolicyEngineCountryId,
} from './PolicyEngineSiteConfig';

export interface PolicyEngineHeaderProps
extends Omit<
HeaderProps,
'navItems' | 'countries' | 'currentCountry' | 'logoHref' | 'onCountryChange'
> {
country?: PolicyEngineCountryId;
baseUrl?: string;
navItems?: NavItemConfig[];
countries?: CountryConfig[];
logoHref?: string;
onCountryChange?: (id: string) => void;
}

export function PolicyEngineHeader({
country = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
navItems,
countries = policyEngineCountries,
logoHref,
onCountryChange,
...props
}: PolicyEngineHeaderProps) {
const handleCountryChange =
onCountryChange ??
((countryId: string) => {
if (typeof window !== 'undefined') {
window.location.href = getPolicyEngineCountryUrl(countryId, baseUrl);
}
});

return (
<Header
navItems={navItems ?? getPolicyEngineNavItems(country, baseUrl)}
countries={countries}
currentCountry={country}
logoHref={logoHref ?? getPolicyEngineCountryUrl(country, baseUrl)}
onCountryChange={handleCountryChange}
{...props}
/>
);
}
39 changes: 39 additions & 0 deletions src/layout/PolicyEngineShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import type { ReactNode } from 'react';
import { cn } from '../utils/cn';
import { PolicyEngineFooter, type PolicyEngineFooterProps } from './PolicyEngineFooter';
import { PolicyEngineHeader, type PolicyEngineHeaderProps } from './PolicyEngineHeader';
import { DEFAULT_POLICYENGINE_BASE_URL, type PolicyEngineCountryId } from './PolicyEngineSiteConfig';

export interface PolicyEngineShellProps {
children: ReactNode;
country?: PolicyEngineCountryId;
baseUrl?: string;
showFooter?: boolean;
headerProps?: Omit<PolicyEngineHeaderProps, 'country' | 'baseUrl'>;
footerProps?: Omit<PolicyEngineFooterProps, 'country' | 'baseUrl'>;
className?: string;
mainClassName?: string;
}

export function PolicyEngineShell({
children,
country = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
showFooter = true,
headerProps,
footerProps,
className,
mainClassName,
}: PolicyEngineShellProps) {
return (
<div className={cn('min-h-screen flex flex-col', className)}>
<PolicyEngineHeader country={country} baseUrl={baseUrl} {...headerProps} />
<main className={cn('flex-1', mainClassName)}>{children}</main>
{showFooter && (
<PolicyEngineFooter country={country} baseUrl={baseUrl} {...footerProps} />
)}
</div>
);
}
102 changes: 102 additions & 0 deletions src/layout/PolicyEngineSiteConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { CountryConfig, NavItemConfig } from './header';
import type { FooterProps } from './Footer';

export type PolicyEngineCountryId = 'us' | 'uk' | (string & {});

export const DEFAULT_POLICYENGINE_BASE_URL = 'https://policyengine.org';

export const policyEngineCountries: CountryConfig[] = [
{ id: 'us', label: 'United States' },
{ id: 'uk', label: 'United Kingdom' },
];

function normalizeBaseUrl(baseUrl = DEFAULT_POLICYENGINE_BASE_URL) {
return baseUrl.replace(/\/+$/, '');
}

export function getPolicyEngineCountryUrl(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
) {
return `${normalizeBaseUrl(baseUrl)}/${country}`;
}

export function getPolicyEngineUrl(
country: PolicyEngineCountryId = 'us',
path = '',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${getPolicyEngineCountryUrl(country, baseUrl)}${normalizedPath}`;
}

export function getPolicyEngineNavItems(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
): NavItemConfig[] {
return [
{
label: 'Research',
href: getPolicyEngineUrl(country, 'research', baseUrl),
},
{
label: 'Model',
href: getPolicyEngineUrl(country, 'model', baseUrl),
},
{
label: 'API',
href: getPolicyEngineUrl(country, 'api', baseUrl),
},
{
label: 'Python',
href: getPolicyEngineUrl(country, 'python', baseUrl),
},
{
label: 'About',
href: getPolicyEngineUrl(country, 'team', baseUrl),
children: [
{
label: 'Team',
href: getPolicyEngineUrl(country, 'team', baseUrl),
description: 'Meet the PolicyEngine team',
},
{
label: 'Supporters',
href: getPolicyEngineUrl(country, 'supporters', baseUrl),
description: 'Organizations supporting PolicyEngine',
},
{
label: 'Citations',
href: getPolicyEngineUrl(country, 'citations', baseUrl),
description: 'How to cite PolicyEngine',
},
],
},
{
label: 'Donate',
href: getPolicyEngineUrl(country, 'donate', baseUrl),
},
];
}

export function getPolicyEngineFooterLinks(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
): NonNullable<FooterProps['links']> {
return [
{ text: 'About us', href: getPolicyEngineUrl(country, 'team', baseUrl) },
{ text: 'Donate', href: getPolicyEngineUrl(country, 'donate', baseUrl) },
{
text: 'Developer tools',
href: getPolicyEngineUrl(country, 'dev-tools', baseUrl),
},
{
text: 'Privacy policy',
href: getPolicyEngineUrl(country, 'privacy', baseUrl),
},
{
text: 'Terms and conditions',
href: getPolicyEngineUrl(country, 'terms', baseUrl),
},
];
}
21 changes: 21 additions & 0 deletions src/layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ export { SidebarNavItem, type SidebarNavItemProps } from './SidebarNavItem';
export { SidebarSection, type SidebarSectionProps } from './SidebarSection';
export { SidebarDivider } from './SidebarDivider';
export { Footer, type FooterProps } from './Footer';
export {
PolicyEngineFooter,
type PolicyEngineFooterProps,
} from './PolicyEngineFooter';
export {
PolicyEngineHeader,
type PolicyEngineHeaderProps,
} from './PolicyEngineHeader';
export {
PolicyEngineShell,
type PolicyEngineShellProps,
} from './PolicyEngineShell';
export {
DEFAULT_POLICYENGINE_BASE_URL,
getPolicyEngineCountryUrl,
getPolicyEngineFooterLinks,
getPolicyEngineNavItems,
getPolicyEngineUrl,
policyEngineCountries,
type PolicyEngineCountryId,
} from './PolicyEngineSiteConfig';
export {
Header,
type HeaderProps,
Expand Down
39 changes: 39 additions & 0 deletions tests/layout/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { SidebarLayout } from '../../src/layout/SidebarLayout';
import { SingleColumnLayout } from '../../src/layout/SingleColumnLayout';
import { Header } from '../../src/layout/header';
import { InputPanel } from '../../src/layout/InputPanel';
import {
getPolicyEngineNavItems,
PolicyEngineFooter,
PolicyEngineHeader,
PolicyEngineShell,
} from '../../src/layout';
import { ResultsPanel } from '../../src/layout/ResultsPanel';

describe('DashboardShell', () => {
Expand Down Expand Up @@ -44,6 +50,39 @@ describe('Header', () => {
});
});

describe('PolicyEngine shell components', () => {
it('builds country-aware navigation URLs', () => {
const navItems = getPolicyEngineNavItems('uk');

expect(navItems).toContainEqual({
label: 'API',
href: 'https://policyengine.org/uk/api',
});
});

it('renders the canonical PolicyEngine header', () => {
render(<PolicyEngineHeader country="uk" />);

expect(screen.getAllByText('Research').length).toBeGreaterThan(0);
expect(screen.getAllByText('Model').length).toBeGreaterThan(0);
expect(screen.getAllByText('API').length).toBeGreaterThan(0);
});

it('renders the canonical PolicyEngine footer', () => {
render(<PolicyEngineFooter country="us" />);

expect(screen.getByText('Privacy policy')).toBeInTheDocument();
expect(screen.getByText(/PolicyEngine/)).toBeInTheDocument();
});

it('wraps app content with the canonical PolicyEngine shell', () => {
render(<PolicyEngineShell country="us">Tool content</PolicyEngineShell>);

expect(screen.getByText('Tool content')).toBeInTheDocument();
expect(screen.getAllByText('Donate').length).toBeGreaterThan(0);
});
});

describe('InputPanel', () => {
it('renders title and children', () => {
render(<InputPanel title="Settings">Fields</InputPanel>);
Expand Down