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
37 changes: 37 additions & 0 deletions backend/src/components/utils/__tests__/categorization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';

import { categorizeComponent, getCategoryConfig } from '../categorization';

describe('component categorization', () => {
it('prefers UI category when present', () => {
const component = {
id: 'core.aws.org-discovery',
category: 'transform',
ui: { category: 'cloud' },
} as any;

expect(categorizeComponent(component)).toBe('cloud');
});

it('falls back to definition category and defaults for unknown values', () => {
const fromDefinition = {
id: 'core.integration.resolve-credentials',
category: 'core',
} as any;
const unknown = {
id: 'core.unknown',
category: 'not-a-category',
} as any;

expect(categorizeComponent(fromDefinition)).toBe('core');
expect(categorizeComponent(unknown)).toBe('input');
});

it('returns shared descriptor configuration', () => {
const config = getCategoryConfig('process');

expect(config.label).toBe('Process');
expect(config.icon).toBe('Cog');
expect(config.color).toContain('text-slate');
});
});
112 changes: 16 additions & 96 deletions backend/src/components/utils/categorization.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ComponentDefinition, ComponentCategory } from '@shipsec/component-sdk';
import type { ComponentDefinition } from '@shipsec/component-sdk';
import {
type ComponentCategory,
getComponentCategoryDescriptor,
normalizeComponentCategory,
} from '@shipsec/shared';

export interface ComponentCategoryConfig {
label: string;
Expand All @@ -8,105 +13,13 @@ export interface ComponentCategoryConfig {
icon: string;
}

const SUPPORTED_CATEGORIES: readonly ComponentCategory[] = [
'input',
'transform',
'ai',
'mcp',
'security',
'it_ops',
'notification',
'manual_action',
'output',
];

const COMPONENT_CATEGORY_CONFIG: Record<ComponentCategory, ComponentCategoryConfig> = {
input: {
label: 'Input',
color: 'text-blue-600',
description: 'Data sources, triggers, and credential access',
emoji: '📥',
icon: 'Download',
},
transform: {
label: 'Transform',
color: 'text-orange-600',
description: 'Data processing, text manipulation, and formatting',
emoji: '🔄',
icon: 'RefreshCw',
},
ai: {
label: 'AI Components',
color: 'text-violet-600',
description: 'AI-powered analysis and generation tools',
emoji: '🤖',
icon: 'Brain',
},
mcp: {
label: 'MCP Servers',
color: 'text-teal-600',
description: 'Model Context Protocol servers and tool gateways',
emoji: '🔌',
icon: 'Plug',
},
security: {
label: 'Security Tools',
color: 'text-red-600',
description: 'Security scanning and assessment tools',
emoji: '🔒',
icon: 'Shield',
},
it_ops: {
label: 'IT Ops',
color: 'text-cyan-600',
description: 'IT operations and user management workflows',
emoji: '🏢',
icon: 'Building',
},
notification: {
label: 'Notification',
color: 'text-pink-600',
description: 'Slack, Email, and other messaging alerts',
emoji: '🔔',
icon: 'Bell',
},
manual_action: {
label: 'Manual Action',
color: 'text-amber-600',
description: 'Human-in-the-loop interactions, approvals, and manual tasks',
emoji: '👤',
icon: 'UserCheck',
},
output: {
label: 'Output',
color: 'text-green-600',
description: 'Data export, notifications, and integrations',
emoji: '📤',
icon: 'Upload',
},
};

function normalizeCategory(category?: string | null): ComponentCategory | null {
if (!category) {
return null;
}

const normalized = category.toLowerCase();

if (SUPPORTED_CATEGORIES.includes(normalized as ComponentCategory)) {
return normalized as ComponentCategory;
}

return null;
}

export function categorizeComponent(component: ComponentDefinition): ComponentCategory {
const fromMetadata = normalizeCategory(component.ui?.category);
const fromMetadata = normalizeComponentCategory(component.ui?.category);
if (fromMetadata) {
return fromMetadata;
}

const fromDefinition = normalizeCategory(component.category);
const fromDefinition = normalizeComponentCategory(component.category);
if (fromDefinition) {
return fromDefinition;
}
Expand All @@ -115,5 +28,12 @@ export function categorizeComponent(component: ComponentDefinition): ComponentCa
}

export function getCategoryConfig(category: ComponentCategory): ComponentCategoryConfig {
return COMPONENT_CATEGORY_CONFIG[category];
const descriptor = getComponentCategoryDescriptor(category);
return {
label: descriptor.label,
color: descriptor.color,
description: descriptor.description,
emoji: descriptor.emoji,
icon: descriptor.icon,
};
}
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/development/component-development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ worker/src/components/
└── manual-action/ # Human-in-the-loop (approvals, forms)
```

### Category Source of Truth

Component categories are defined once in `packages/shared/src/component-categories.ts`.

- Backend categorization and API metadata read from this shared registry.
- Frontend schema validation and category styling also read from the same registry.

When adding or renaming a category, update this shared file so backend and frontend stay in sync.

### ID Naming Convention

```
Expand Down
35 changes: 7 additions & 28 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import type { ComponentMetadata } from '@/schemas/component';
import { cn } from '@/lib/utils';
import { env } from '@/config/env';
import { Skeleton } from '@/components/ui/skeleton';
import { type ComponentCategory, getCategorySeparatorColor } from '@/utils/categoryColors';
import { COMPONENT_CATEGORY_ORDER, isComponentCategory } from '@shipsec/shared';
import { getCategorySeparatorColor, getCategoryTextColorClass } from '@/utils/categoryColors';
import { useThemeStore } from '@/store/themeStore';
import { useWorkflowUiStore } from '@/store/workflowUiStore';
import { useWorkflowStore } from '@/store/workflowStore';
Expand Down Expand Up @@ -210,23 +211,13 @@ export function Sidebar({ canManageWorkflows = true }: SidebarProps) {
const hasBranchInfo = Boolean(frontendBranch || backendBranch);

// Get category accent color (for left border) - uses separator colors for brightness
const getCategoryAccentColor = (category: string): string | undefined => {
return getCategorySeparatorColor(category as ComponentCategory, isDarkMode);
const getCategoryAccentColor = (category: string): string => {
return getCategorySeparatorColor(category, isDarkMode);
};

// Get category text color with good contrast in both light and dark modes
const getCategoryTextColor = (category: string): string => {
const categoryColors: Record<string, string> = {
input: 'text-blue-600 dark:text-blue-400',
transform: 'text-orange-600 dark:text-orange-400',
ai: 'text-purple-600 dark:text-purple-400',
mcp: 'text-teal-600 dark:text-teal-400',
security: 'text-red-600 dark:text-red-400',
it_ops: 'text-cyan-600 dark:text-cyan-400',
notification: 'text-pink-600 dark:text-pink-400',
output: 'text-green-600 dark:text-green-400',
};
return categoryColors[category] || 'text-foreground';
return getCategoryTextColorClass(category);
};

// Custom scrollbar state
Expand Down Expand Up @@ -300,18 +291,6 @@ export function Sidebar({ canManageWorkflows = true }: SidebarProps) {
);
}, [filteredComponents]);

// Category display order
const categoryOrder = [
'input',
'output',
'notification',
'security',
'mcp',
'ai',
'transform',
'it_ops',
] as const;

// Filter components based on search query
const filteredComponentsByCategory = useMemo(() => {
const filtered = searchQuery.trim()
Expand Down Expand Up @@ -351,8 +330,8 @@ export function Sidebar({ canManageWorkflows = true }: SidebarProps) {

// Sort categories by predefined order
const sortedEntries = Object.entries(filtered).sort(([a], [b]) => {
const indexA = categoryOrder.indexOf(a as (typeof categoryOrder)[number]);
const indexB = categoryOrder.indexOf(b as (typeof categoryOrder)[number]);
const indexA = isComponentCategory(a) ? COMPONENT_CATEGORY_ORDER.indexOf(a) : -1;
const indexB = isComponentCategory(b) ? COMPONENT_CATEGORY_ORDER.indexOf(b) : -1;
// If category not in order list, put it at the end
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/components/workflow/node/WorkflowNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import type { InputPort } from '@/schemas/component';
import { useWorkflowUiStore } from '@/store/workflowUiStore';
import { useThemeStore } from '@/store/themeStore';
import {
type ComponentCategory,
getCategorySeparatorColor,
getCategoryHeaderBackgroundColor,
} from '@/utils/categoryColors';
Expand Down Expand Up @@ -152,8 +151,7 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps<NodeData>) => {
const isTextBlock = component?.id === 'core.ui.text';
const isEntryPoint = component?.id === 'core.workflow.entrypoint';
const isDarkMode = theme === 'dark';
const componentCategory: ComponentCategory =
(component?.category as ComponentCategory) || (isEntryPoint ? 'input' : 'input');
const componentCategory = component?.category ?? 'input';
const isToolModeOnly = component?.id ? TOOL_MODE_ONLY_COMPONENTS.has(component.id) : false;
const showMcpBadge = componentCategory === 'mcp' || isToolModeOnly;
const isToolMode = Boolean(
Expand Down
13 changes: 2 additions & 11 deletions frontend/src/schemas/component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { COMPONENT_CATEGORIES } from '@shipsec/shared';

export const ComponentRunnerSchema = z
.object({
Expand Down Expand Up @@ -175,17 +176,7 @@ export const ComponentMetadataSchema = z.object({
name: z.string().min(1),
version: z.string().default('1.0.0'),
type: z.enum(['trigger', 'input', 'scan', 'process', 'output']),
category: z.enum([
'input',
'transform',
'ai',
'mcp',
'security',
'it_ops',
'notification',
'manual_action',
'output',
]),
category: z.enum(COMPONENT_CATEGORIES),
categoryConfig: ComponentCategoryConfigSchema.optional().default({
label: 'Uncategorized',
color: 'text-muted-foreground',
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/utils/__tests__/categoryColors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test';

import {
getCategoryHeaderBackgroundColor,
getCategorySeparatorColor,
getCategoryTextColorClass,
} from '../categoryColors';

describe('categoryColors', () => {
it('supports newly added categories', () => {
expect(getCategorySeparatorColor('core', false)).toBeTruthy();
expect(getCategorySeparatorColor('cloud', true)).toBeTruthy();
expect(getCategoryHeaderBackgroundColor('process', false)).toBeTruthy();
});

it('falls back to input category for unknown values', () => {
expect(getCategorySeparatorColor('unknown-category', false)).toBe(
getCategorySeparatorColor('input', false),
);
expect(getCategoryTextColorClass('unknown-category')).toBe(getCategoryTextColorClass('input'));
});

it('normalizes category values', () => {
expect(getCategorySeparatorColor(' CLOUD ', true)).toBe(
getCategorySeparatorColor('cloud', true),
);
});
});
Loading