Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"license": "MIT",
"scripts": {
"dev": "yarn uilib:update && next dev --turbopack -p 3003",
"build": "yarn uilib:update && next build",
"build:extensions": "npx tsx scripts/build-extensions.ts",
"build": "yarn build:extensions && yarn uilib:update && next build",
"start": "next start -p 3000",
"start:cypress": "next start -H 0.0.0.0 -p 3000",
"lint": "tsc && eslint --config .eslintrc.cjs 'src/**/*.{jsx,tsx}'",
Expand Down
97 changes: 97 additions & 0 deletions scripts/build-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as fs from 'fs';
import * as path from 'path';

const EXTENSIONS_DIR = 'extensions';
const OUTPUT_MANIFEST = 'extensions.json';
const APP_DIR = 'src/app/(routegroups)';
const COMPONENTS_DIR = 'src/components/extensions';
const LIB_DIR = 'src/lib/extensions';

type ExtensionManifest = {
meta: { name: string; version: string; description?: string };
navigation?: { items?: unknown[]; sections?: unknown[] };
pages?: { route: string; requiredRoles?: string[] }[];
slots?: unknown[];
features?: Record<string, boolean>;
};

function findExtensions(): string[] {
if (!fs.existsSync(EXTENSIONS_DIR)) {
console.log(`No ${EXTENSIONS_DIR}/ directory found`);
return [];
}
return fs.readdirSync(EXTENSIONS_DIR).filter(name => {
const extPath = path.join(EXTENSIONS_DIR, name);
const manifestPath = path.join(extPath, 'extension.json');
return fs.statSync(extPath).isDirectory() && fs.existsSync(manifestPath);
});
}

function loadManifest(extName: string): ExtensionManifest | null {
try {
const content = fs.readFileSync(path.join(EXTENSIONS_DIR, extName, 'extension.json'), 'utf-8');
return JSON.parse(content);
} catch (err) {
console.error(`Failed to load manifest for ${extName}:`, err);
return null;
}
}

function copyRecursive(src: string, dest: string): void {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
for (const child of fs.readdirSync(src)) {
copyRecursive(path.join(src, child), path.join(dest, child));
}
} else {
fs.copyFileSync(src, dest);
console.log(` ${dest}`);
}
}

function copyPages(extName: string): void {
const pagesDir = path.join(EXTENSIONS_DIR, extName, 'pages');
if (!fs.existsSync(pagesDir)) return;
console.log(` Copying pages...`);
copyRecursive(pagesDir, APP_DIR);
}

function copyComponents(extName: string): void {
const componentsDir = path.join(EXTENSIONS_DIR, extName, 'components');
if (!fs.existsSync(componentsDir)) return;
const destDir = path.join(COMPONENTS_DIR, extName);
console.log(` Copying components...`);
copyRecursive(componentsDir, destDir);
}

function copyLib(extName: string): void {
const libDir = path.join(EXTENSIONS_DIR, extName, 'lib');
if (!fs.existsSync(libDir)) return;
const destDir = path.join(LIB_DIR, extName);
console.log(` Copying lib...`);
copyRecursive(libDir, destDir);
}

function main(): void {
console.log('Building extensions...\n');
const extensions = findExtensions();
console.log(`Found ${extensions.length} extension(s): ${extensions.join(', ') || '(none)'}\n`);

const manifests: ExtensionManifest[] = [];
for (const extName of extensions) {
console.log(`Processing: ${extName}`);
const manifest = loadManifest(extName);
if (!manifest) continue;
copyPages(extName);
copyComponents(extName);
copyLib(extName);
manifests.push(manifest);
console.log(` ✓ ${manifest.meta.name} v${manifest.meta.version}\n`);
}

fs.writeFileSync(OUTPUT_MANIFEST, JSON.stringify({ extensions: manifests }, null, 2));
console.log(`\nWrote ${OUTPUT_MANIFEST} with ${manifests.length} extension(s)`);
}

main();
17 changes: 11 additions & 6 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import fs from 'fs';
import {OverrideProvider} from "@/contexts/OverrideContext";
import * as process from "node:process";
import { validateOverrides, type Overrides } from '@uselagoon/ui-library/schemas';
import { ExtensionProvider } from '@/contexts/ExtensionContext';
import { loadExtensions } from '@/lib/extensions/loader';

function loadOverrides() : Overrides {
try {
Expand Down Expand Up @@ -61,6 +63,7 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const overrides = loadOverrides();
const extensions = loadExtensions();
// ref for exposing custom variables at runtime: https://github.com/expatfile/next-runtime-env/blob/development/docs/EXPOSING_CUSTOM_ENV.md
noStore();
return (
Expand All @@ -74,12 +77,14 @@ export default async function RootLayout({
<ProgressProvider>
<LinkProvider>
<AuthProvider>
<RefreshTokenHandler />
<ClientSessionWrapper>
<ApolloClientComponentWrapper>
<AppProvider kcUrl={process.env.AUTH_KEYCLOAK_ISSUER!}>{children}</AppProvider>
</ApolloClientComponentWrapper>
</ClientSessionWrapper>
<ExtensionProvider extensions={extensions}>
<RefreshTokenHandler />
<ClientSessionWrapper>
<ApolloClientComponentWrapper>
<AppProvider kcUrl={process.env.AUTH_KEYCLOAK_ISSUER!}>{children}</AppProvider>
</ApolloClientComponentWrapper>
</ClientSessionWrapper>
</ExtensionProvider>
</AuthProvider>
</LinkProvider>
<Plugins hook="body" />
Expand Down
48 changes: 46 additions & 2 deletions src/components/dynamicNavigation/useSidenavItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { SidebarSection } from '@/contexts/AppContext';
import environmentWithProblems from '@/lib/query/environmentWithProblems';
import projectByNameQuery from '@/lib/query/projectByNameQuery';
import { useQuery } from '@apollo/client';
import { BriefcaseBusiness, FolderGit2, KeyRound, ListChecks, ServerCog, UserRoundCog } from 'lucide-react';
import { BriefcaseBusiness, FolderGit2, KeyRound, ListChecks, ServerCog } from 'lucide-react';

import { getOrgNav, getProjectNav } from './DynamicNavigation';
import { useExtensions } from '@/contexts/ExtensionContext';
import { resolveIcon } from '@/lib/extensions/icons';

const getBaseSidenavItems = (kcUrl: string): SidebarSection[] => [
{
Expand Down Expand Up @@ -52,6 +54,7 @@ export function useSidenavItems(
const pathname = usePathname();

const { LAGOON_UI_VIEW_ENV_VARIABLES } = useEnvContext();
const { getNavItemsForTarget, getSidebarSections } = useExtensions();

const { data: projectData, loading: projectLoading } = useQuery(projectByNameQuery, {
variables: { name: projectSlug },
Expand Down Expand Up @@ -84,8 +87,49 @@ export function useSidenavItems(
items[2].sectionItems[0].children = orgChildren;
}

// Add extension sidebar sections
const extensionSections = getSidebarSections();
for (const section of extensionSections) {
const newSection = {
section: section.section,
sectionItems: section.items.map(item => ({
title: item.label,
url: item.href,
icon: resolveIcon(item.icon),
})),
};
if (section.position === 'start') {
items.unshift(newSection);
} else if (typeof section.position === 'number') {
items.splice(section.position, 0, newSection);
} else {
items.push(newSection);
}
}

// Add extension items to existing sections
const targetToIndex: Record<string, number> = {
'sidebar-projects': 0,
'sidebar-deployments': 1,
'sidebar-organizations': 2,
'sidebar-settings': 3,
};
for (const [target, idx] of Object.entries(targetToIndex)) {
const extItems = getNavItemsForTarget(target as any);
if (extItems.length > 0 && items[idx]) {
for (const extItem of extItems) {
const navItem = { title: extItem.label, url: extItem.href, icon: resolveIcon(extItem.icon) };
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
Copy link
Member

@shreddedbacon shreddedbacon Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're aware that there are some issues with the current sidebar implementation, and are working on a different way the sidebar is managed that will probably change how what I've made in this suggestion work. Probably ignore this suggestion for now until that other work goes through properly.

In saying that, the sidebar doesn't render properly when using project-tabs in the provided example, but also doesn't render properly when using sidebar-projects without changes below

Note: also pretty sure project-tabs,environment-tabs,organization-tabs, and settings-tabs are deprecated, so probably don't need to exist.

Suggested change
const navItem = { title: extItem.label, url: extItem.href, icon: resolveIcon(extItem.icon) };
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
let href = extItem.href
if (projectSlug) {
href = href.replace('[projectSlug]', projectSlug as string )
}
if (environmentSlug) {
href = href.replace('[environmentSlug]', environmentSlug as string )
}
const navItem = { title: extItem.label, url: href, icon: resolveIcon(extItem.icon)};
switch (target) {
case 'sidebar-projects':
console.log(items[idx].sectionItems[0])
if (items[idx].sectionItems[0].children) {
if (extItem.subTarget == 'environment') {
if (items[idx].sectionItems[0].children[0].children) {
if (items[idx].sectionItems[0].children[0].children[0].children) {
if (extItem.position === 'start') {
items[idx].sectionItems[0].children[0].children[0].children[0].children?.unshift(navItem);
} else {
items[idx].sectionItems[0].children[0].children[0].children[0].children?.push(navItem);
}
}
}
} else {
if (extItem.position === 'start') {
items[idx].sectionItems[0].children[0].children?.unshift(navItem);
} else {
items[idx].sectionItems[0].children[0].children?.push(navItem);
}
}
}
break;
default:
if (extItem.position === 'start') {
items[idx].sectionItems.unshift(navItem);
} else {
items[idx].sectionItems.push(navItem);
}
break;
}

This would need further work to properly support if needing to extend adding to the environments or others too.

diff --git a/src/lib/extensions/types.ts b/src/lib/extensions/types.ts
index c75187d3..46a81701 100644
--- a/src/lib/extensions/types.ts
+++ b/src/lib/extensions/types.ts
@@ -8,6 +8,9 @@ export type ExtensionNavTarget =
   | 'organization-tabs'
   | 'settings-tabs';
 
+export type ExtensionNavSubTarget =
+  | 'environment';
+
 export type ExtensionSlotLocation =
   | 'project-header'
   | 'project-footer'
@@ -24,6 +27,7 @@ export type ExtensionNavItem = {
   href: string;
   icon?: string;
   target: ExtensionNavTarget;
+  subTarget: ExtensionNavSubTarget;
   position?: 'start' | 'end' | number;
   requiredRoles?: string[];
   excludeRoles?: string[];

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok cool, have cleaned up the deprecated *-tabs things and fixed up the slug replacement given they seem like the safe changes here

}
}
}

setSidenavItems(items);
}, [kcUrl, pathname, projectSlug, environmentSlug, organizationSlug, projectData, environmentData]);
}, [kcUrl, pathname, projectSlug, environmentSlug, organizationSlug, projectData, environmentData, getNavItemsForTarget, getSidebarSections, LAGOON_UI_VIEW_ENV_VARIABLES]);

return sidenavItems;
}
15 changes: 15 additions & 0 deletions src/components/environmentNavTabs/EnvironmentNavTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useParams, usePathname } from 'next/navigation';
import environmentWithProblems from '@/lib/query/environmentWithProblems';
import { useQuery } from '@apollo/client';
import { Badge, Skeleton, TabNavigation } from '@uselagoon/ui-library';
import { useExtensions } from '@/contexts/ExtensionContext';

import { LinkContentWrapper } from '../shared/styles';

Expand All @@ -23,6 +24,9 @@ const EnvironmentNavTabs = ({ children }: { children: ReactNode }) => {
const showFactsTab = data?.environment?.project?.factsUi === 1;
const showProblemsTab = data?.environment?.project?.problemsUi === 1;

const { getNavItemsForTarget } = useExtensions();
const extensionTabs = getNavItemsForTarget('environment-tabs');

return (
<section className="flex flex-col gap-4">
<TabNavigation
Expand Down Expand Up @@ -117,6 +121,17 @@ const EnvironmentNavTabs = ({ children }: { children: ReactNode }) => {
</Link>
),
},
...extensionTabs.map(ext => ({
key: ext.id,
label: (
<Link
data-cy={`nav-ext-${ext.id}`}
href={ext.href.replace('[projectSlug]', projectSlug).replace('[environmentSlug]', environmentSlug)}
>
<LinkContentWrapper>{ext.label}</LinkContentWrapper>
</Link>
),
})),
]}
/>
{children}
Expand Down
22 changes: 22 additions & 0 deletions src/components/extensions/ExtensionRouteGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { ReactNode } from 'react';
import { redirect } from 'next/navigation';
import { useExtensions } from '@/contexts/ExtensionContext';

type Props = {
route: string;
children: ReactNode;
fallbackUrl?: string;
};

export function ExtensionRouteGuard({ route, children, fallbackUrl = '/projects' }: Props) {
const { canAccessRoute, getPageConfig } = useExtensions();

if (!canAccessRoute(route)) {
const pageConfig = getPageConfig(route);
redirect(pageConfig?.accessDeniedRedirect || fallbackUrl);
}

return <>{children}</>;
}
14 changes: 13 additions & 1 deletion src/components/projectNavTabs/ProjectNavTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useParams, usePathname } from 'next/navigation';

import projectByNameQuery from '@/lib/query/projectByNameQuery';
import { useQuery } from '@apollo/client';
import { TabNavigation, Tabs } from '@uselagoon/ui-library';
import { TabNavigation } from '@uselagoon/ui-library';
import { useExtensions } from '@/contexts/ExtensionContext';

import { LinkContentWrapper } from '../shared/styles';

Expand All @@ -22,6 +23,9 @@ export const ProjectNavTabs = ({ children }: { children: ReactNode }) => {

const showDeployTargets = data?.project?.deployTargetConfigs?.length > 0;

const { getNavItemsForTarget } = useExtensions();
const extensionTabs = getNavItemsForTarget('project-tabs');

// Do not nest multiple navTabs (project -> environment)
if (environmentSlug) {
return children;
Expand Down Expand Up @@ -75,6 +79,14 @@ export const ProjectNavTabs = ({ children }: { children: ReactNode }) => {
},
]
: []),
...extensionTabs.map(ext => ({
key: ext.id,
label: (
<Link data-cy={`nav-ext-${ext.id}`} href={ext.href.replace('[projectSlug]', projectSlug)}>
<LinkContentWrapper>{ext.label}</LinkContentWrapper>
</Link>
),
})),
]}
/>

Expand Down
Loading
Loading