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
3 changes: 1 addition & 2 deletions src/components/Configs/ConfigDetailsTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import TabbedLinks from "../../ui/Tabs/TabbedLinks";
import PlaybooksDropdownMenu from "../Playbooks/Runs/Submit/PlaybooksDropdownMenu";
import { ErrorBoundary } from "../ErrorBoundary";
import { useConfigDetailsTabs } from "./ConfigTabsLinks";
import { ConfigPluginsDropdown } from "./ConfigPluginsDropdown";
import ConfigSidebar from "./Sidebar/ConfigSidebar";

type ConfigDetailsTabsProps = {
Expand Down Expand Up @@ -94,7 +93,7 @@ export function ConfigDetailsTabs({
<TabbedLinks
activeTabName={activeTabName}
tabLinks={configTabList}
extraTabs={<ConfigPluginsDropdown />}
overflowMenu
contentClassName={clsx(
"bg-white border border-t-0 border-gray-300 flex-1 min-h-0 overflow-auto",
className
Expand Down
67 changes: 0 additions & 67 deletions src/components/Configs/ConfigPluginsDropdown.tsx

This file was deleted.

33 changes: 27 additions & 6 deletions src/components/Configs/ConfigTabsLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import { Badge } from "@flanksource-ui/ui/Badge/Badge";
import { useParams } from "react-router-dom";
import { ConfigItem } from "../../api/types/configs";
import { getViewsByConfigId } from "../../api/services/views";
import {
getPluginsForConfig,
pluginTabKey,
pluginTabPath
} from "../../api/services/configPlugins";
import { useQuery } from "@tanstack/react-query";
import { Icon } from "@flanksource-ui/ui/Icons/Icon";
import { ReactNode } from "react";
import useConfigAccessSummaryQuery from "@flanksource-ui/api/query-hooks/useConfigAccessSummaryQuery";
import useConfigAccessLogsQuery from "@flanksource-ui/api/query-hooks/useConfigAccessLogsQuery";
import { PluginIcon } from "./PluginIcon";

type ConfigDetailsTab = {
label: ReactNode;
Expand All @@ -33,6 +39,12 @@ export function useConfigDetailsTabs(countSummary?: ConfigItem["summary"]): {
enabled: !!id
});

const { data: plugins = [] } = useQuery({
queryKey: ["config", "plugins", id],
queryFn: () => getPluginsForConfig(id!),
enabled: !!id
});

const { data: accessSummary } = useConfigAccessSummaryQuery(id);
const accessCount =
accessSummary?.totalEntries ?? accessSummary?.data?.length ?? 0;
Expand Down Expand Up @@ -167,10 +179,19 @@ export function useConfigDetailsTabs(countSummary?: ConfigItem["summary"]): {
icon: <Icon name={view.icon || "workflow"} />
}));

if (viewTabs.length === 0) {
return { isLoading, isError, tabs: staticTabs };
}

// Views configured for a config should appear ahead of the built-in tabs.
return { isLoading, isError, tabs: [...viewTabs, ...staticTabs] };
const pluginTabs: ConfigDetailsTab[] = plugins.flatMap((plugin) =>
(plugin.tabs ?? []).map((tab) => ({
label: tab.name,
key: pluginTabKey(plugin.name, tab.name),
path: pluginTabPath(id!, plugin.name, tab.name),
icon: <PluginIcon name={tab.icon} />
}))
);

// Views lead the built-in tabs; plugin-contributed tabs trail them.
return {
isLoading,
isError,
tabs: [...viewTabs, ...staticTabs, ...pluginTabs]
};
}
197 changes: 180 additions & 17 deletions src/ui/Tabs/TabbedLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@flanksource-ui/components/ui/dropdown-menu";
import clsx from "clsx";
import React from "react";
import { NavLink } from "react-router-dom";
import { ChevronDown } from "lucide-react";
import React, {
useCallback,
useLayoutEffect,
useRef,
useState
} from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";

type TabLink = {
label: React.ReactNode;
path: string;
icon?: React.ReactNode;
search?: string;
key?: string;
};

type RoutedTabsLinksProps = React.HTMLProps<HTMLDivElement> & {
children?: React.ReactNode;
contentClassName?: string;
containerClassName?: string;
activeTabName?: string;
tabLinks: {
label: React.ReactNode;
path: string;
icon?: React.ReactNode;
search?: string;
key?: string;
}[];
tabLinks: TabLink[];
// extraTabs renders custom controls (e.g. a dropdown tab) inline at the end
// of the tab row, after the routed links.
extraTabs?: React.ReactNode;
// overflowMenu keeps the tab row on a single line and collapses the tabs that
// don't fit into a trailing dropdown, preserving the original tab order.
overflowMenu?: boolean;
};

// Space to reserve for the overflow dropdown trigger before it has been
// measured, so the first layout pass leaves room for it.
const OVERFLOW_TRIGGER_WIDTH = 80;

const tabClassName = (isActive: boolean, overflowMenu: boolean) =>
clsx(
"mb-[-2px] cursor-pointer rounded-t-md border border-b-0 border-gray-300 px-4 py-2 text-sm font-medium hover:text-gray-900",
overflowMenu && "shrink-0 whitespace-nowrap",
isActive
? "bg-white text-gray-900"
: "border-transparent text-gray-500"
);

export default function TabbedLinks({
children,
className,
Expand All @@ -27,24 +57,127 @@ export default function TabbedLinks({
tabLinks,
activeTabName,
extraTabs,
overflowMenu = false,
...rest
}: RoutedTabsLinksProps) {
const navigate = useNavigate();
const { pathname } = useLocation();
const tabsRef = useRef<HTMLDivElement>(null);
const tabWidthsRef = useRef<number[] | null>(null);
const triggerWidthRef = useRef(OVERFLOW_TRIGGER_WIDTH);
const [visibleCount, setVisibleCount] = useState(tabLinks.length);
const tabKeys = tabLinks.map(({ key, path }) => key ?? path).join(",");

// Fit as many leading tabs as the row can show, reserving room for the
// overflow trigger when some tabs must be collapsed. Uses widths captured
// during the full-render measure pass, so it can run on every resize without
// re-rendering all the tabs.
const fitTabs = useCallback(() => {
const container = tabsRef.current;
const widths = tabWidthsRef.current;
if (!container || !widths) {
return;
}
const available = container.clientWidth;
const total = widths.reduce((sum, width) => sum + width, 0);
if (total <= available) {
setVisibleCount(widths.length);
return;
}
let used = 0;
let count = 0;
for (const width of widths) {
if (used + width + triggerWidthRef.current > available) {
break;
}
used += width;
count += 1;
}
setVisibleCount(Math.max(count, 1));
Comment on lines +87 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Allow zero visible tabs when only the overflow trigger fits.

Line 96 forces one tab to remain visible even when used + firstTabWidth + triggerWidth > available; with overflow-hidden, the trailing “More” trigger can be clipped off and the overflowed tabs become unreachable.

Proposed fix
-    setVisibleCount(Math.max(count, 1));
+    setVisibleCount(count);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let used = 0;
let count = 0;
for (const width of widths) {
if (used + width + triggerWidthRef.current > available) {
break;
}
used += width;
count += 1;
}
setVisibleCount(Math.max(count, 1));
let used = 0;
let count = 0;
for (const width of widths) {
if (used + width + triggerWidthRef.current > available) {
break;
}
used += width;
count += 1;
}
setVisibleCount(count);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/Tabs/TabbedLinks.tsx` around lines 87 - 96, Allow zero visible tabs in
TabbedLinks when only the overflow trigger fits, instead of forcing at least one
tab visible. Update the visible-count calculation in the loop that uses widths,
available, and triggerWidthRef.current so setVisibleCount can receive 0 when the
first tab plus the trigger exceeds the container, ensuring the “More” trigger
remains reachable under overflow-hidden.

}, []);

// Re-measure whenever the tab set changes: show every tab, capture their
// natural widths, then let fitTabs collapse the overflow.
useLayoutEffect(() => {
if (!overflowMenu) {
setVisibleCount(tabLinks.length);
return;
}
tabWidthsRef.current = null;
setVisibleCount(tabLinks.length);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overflowMenu, tabKeys]);

useLayoutEffect(() => {
if (!overflowMenu) {
return;
}
const container = tabsRef.current;
if (!container) {
return;
}
if (tabWidthsRef.current === null) {
// Only measure once every tab is rendered (the full-render pass).
if (visibleCount !== tabLinks.length) {
return;
}
const nodes =
container.querySelectorAll<HTMLElement>("[data-tab-link]");
if (nodes.length !== tabLinks.length) {
return;
}
tabWidthsRef.current = Array.from(nodes).map(
(node) => node.getBoundingClientRect().width
);
}
const trigger =
container.querySelector<HTMLElement>("[data-tab-overflow]");
if (trigger) {
// Round up and pad so the row always reserves enough room to show the
// full "More ▾" trigger without the chevron being clipped.
triggerWidthRef.current =
Math.ceil(trigger.getBoundingClientRect().width) + 4;
}
fitTabs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overflowMenu, tabKeys, visibleCount, fitTabs]);

useLayoutEffect(() => {
if (!overflowMenu) {
return;
}
const container = tabsRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver(() => fitTabs());
observer.observe(container);
return () => observer.disconnect();
}, [overflowMenu, fitTabs]);

const visibleTabs = overflowMenu ? tabLinks.slice(0, visibleCount) : tabLinks;
const overflowTabs = overflowMenu ? tabLinks.slice(visibleCount) : [];
const isOverflowActive = overflowTabs.some(
({ key, path }) => activeTabName === key || pathname === path
);
Comment on lines +160 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard activeTabName === key against keyless tabs.

Both activeTabName and TabLink.key are optional, so undefined === undefined marks every keyless tab active when activeTabName is omitted, including the overflow trigger/items.

Proposed fix
-  const isOverflowActive = overflowTabs.some(
-    ({ key, path }) => activeTabName === key || pathname === path
-  );
+  const isOverflowActive = overflowTabs.some(
+    ({ key, path }) =>
+      (key != null && activeTabName === key) || pathname === path
+  );
...
-              tabClassName(isActive || activeTabName === key, overflowMenu)
+              tabClassName(
+                isActive || (key != null && activeTabName === key),
+                overflowMenu
+              )
...
-                    (activeTabName === key || pathname === path) &&
+                    ((key != null && activeTabName === key) ||
+                      pathname === path) &&

Also applies to: 176-177, 209-212

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/Tabs/TabbedLinks.tsx` around lines 157 - 159, The active-tab check in
TabbedLinks is treating missing keys as a match because activeTabName and
TabLink.key are both optional, so guard the activeTabName === key comparison
against undefined before using it in isOverflowActive and the other tab
selection checks. Update the matching logic in TabbedLinks to only compare keys
when both values are present, while still allowing pathname-based matching for
tabs without keys, including the overflow trigger and overflow items.


return (
<div className={clsx("flex min-h-0 flex-1 flex-col", containerClassName)}>
<div
className={`flex flex-wrap border-b border-gray-300 ${className}`}
ref={tabsRef}
className={clsx(
"flex border-b border-gray-300",
overflowMenu ? "flex-nowrap overflow-hidden" : "flex-wrap",
className
)}
aria-label="Tabs"
{...rest}
>
{tabLinks.map(({ label, path, key, search, icon }) => (
{visibleTabs.map(({ label, path, key, search, icon }) => (
<NavLink
data-tab-link
className={({ isActive }) =>
clsx(
"mb-[-2px] cursor-pointer rounded-t-md border border-b-0 border-gray-300 px-4 py-2 text-sm font-medium hover:text-gray-900",
isActive || activeTabName === key
? "bg-white text-gray-900"
: "border-transparent text-gray-500"
)
tabClassName(isActive || activeTabName === key, overflowMenu)
}
key={path}
to={{
Expand All @@ -58,6 +191,36 @@ export default function TabbedLinks({
</div>
</NavLink>
))}
{overflowTabs.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
data-tab-overflow
aria-label="More tabs"
className={clsx(
tabClassName(isOverflowActive, overflowMenu),
"flex items-center space-x-1 focus:outline-none"
)}
Comment on lines +199 to +202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep a visible keyboard focus state on the overflow trigger.

Line 198 removes the default focus outline without adding a replacement, making the “More” control hard to locate via keyboard navigation.

Proposed fix
-                "flex items-center space-x-1 focus:outline-none"
+                "flex items-center space-x-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
className={clsx(
tabClassName(isOverflowActive, overflowMenu),
"flex items-center space-x-1 focus:outline-none"
)}
className={clsx(
tabClassName(isOverflowActive, overflowMenu),
"flex items-center space-x-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/Tabs/TabbedLinks.tsx` around lines 196 - 199, The overflow trigger in
TabbedLinks should retain a visible keyboard focus indicator instead of
suppressing it with focus:outline-none. Update the className composition for the
“More” control in TabbedLinks so it still has a clear focus style when tabbed
to, using the existing tabClassName/overflowMenu path as the place to add or
restore the focus state.

>
<span>More</span>
<ChevronDown className="h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{overflowTabs.map(({ label, path, key, search, icon }) => (
<DropdownMenuItem
key={path}
onClick={() => navigate({ pathname: path, search })}
className={clsx(
"flex flex-row items-center space-x-2",
(activeTabName === key || pathname === path) &&
"font-medium text-gray-900"
)}
>
{icon} <span>{label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{extraTabs}
</div>
<div className={clsx("flex flex-col", contentClassName)}>{children}</div>
Expand Down
Loading