diff --git a/backend/src/components/utils/__tests__/categorization.spec.ts b/backend/src/components/utils/__tests__/categorization.spec.ts new file mode 100644 index 00000000..6a002116 --- /dev/null +++ b/backend/src/components/utils/__tests__/categorization.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/components/utils/categorization.ts b/backend/src/components/utils/categorization.ts index f3081054..c52fb502 100644 --- a/backend/src/components/utils/categorization.ts +++ b/backend/src/components/utils/categorization.ts @@ -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; @@ -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 = { - 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; } @@ -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, + }; } diff --git a/bun.lock b/bun.lock index 16b97dcf..47a160a2 100644 --- a/bun.lock +++ b/bun.lock @@ -203,6 +203,7 @@ "name": "@shipsec/component-sdk", "version": "0.1.0", "dependencies": { + "@shipsec/shared": "workspace:*", "zod": "^4.3.6", }, "devDependencies": { diff --git a/docs/development/component-development.mdx b/docs/development/component-development.mdx index 148b1d53..6dcc215d 100644 --- a/docs/development/component-development.mdx +++ b/docs/development/component-development.mdx @@ -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 ``` diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c29c9ee5..b9fcd66b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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'; @@ -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 = { - 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 @@ -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() @@ -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; diff --git a/frontend/src/components/workflow/node/WorkflowNode.tsx b/frontend/src/components/workflow/node/WorkflowNode.tsx index 89736b60..796a5cd8 100644 --- a/frontend/src/components/workflow/node/WorkflowNode.tsx +++ b/frontend/src/components/workflow/node/WorkflowNode.tsx @@ -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'; @@ -152,8 +151,7 @@ export const WorkflowNode = ({ data, selected, id }: NodeProps) => { 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( diff --git a/frontend/src/schemas/component.ts b/frontend/src/schemas/component.ts index 8d0ccec2..7ec977c2 100644 --- a/frontend/src/schemas/component.ts +++ b/frontend/src/schemas/component.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { COMPONENT_CATEGORIES } from '@shipsec/shared'; export const ComponentRunnerSchema = z .object({ @@ -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', diff --git a/frontend/src/utils/__tests__/categoryColors.test.ts b/frontend/src/utils/__tests__/categoryColors.test.ts new file mode 100644 index 00000000..16a07ea2 --- /dev/null +++ b/frontend/src/utils/__tests__/categoryColors.test.ts @@ -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), + ); + }); +}); diff --git a/frontend/src/utils/categoryColors.ts b/frontend/src/utils/categoryColors.ts index d0ed4f11..9b3074c3 100644 --- a/frontend/src/utils/categoryColors.ts +++ b/frontend/src/utils/categoryColors.ts @@ -1,144 +1,36 @@ +import { + type ComponentCategory, + DEFAULT_COMPONENT_CATEGORY, + getComponentCategoryDescriptor, + normalizeComponentCategory, +} from '@shipsec/shared'; import { useThemeStore } from '@/store/themeStore'; -// Component category type -export type ComponentCategory = - | 'input' - | 'transform' - | 'ai' - | 'mcp' - | 'security' - | 'it_ops' - | 'notification' - | 'manual_action' - | 'output'; - -/** - * Category-based separator colors (2 shades lighter than normal) - * Only used for the header separator line - */ -export const CATEGORY_SEPARATOR_COLORS: Record = - { - input: { - light: 'rgb(147 197 253)', // blue-300 (2 shades lighter than blue-500) - dark: 'rgb(147 197 253)', // blue-300 (2 shades lighter than blue-400) - }, - transform: { - light: 'rgb(253 186 116)', // orange-300 (2 shades lighter than orange-500) - dark: 'rgb(253 186 116)', // orange-300 (2 shades lighter than orange-400) - }, - ai: { - light: 'rgb(196 181 253)', // violet-300 (2 shades lighter than violet-500) - dark: 'rgb(196 181 253)', // violet-300 (2 shades lighter than violet-400) - }, - mcp: { - light: 'rgb(153 246 228)', // teal-200 - dark: 'rgb(94 234 212)', // teal-300 - }, - security: { - light: 'rgb(252 165 165)', // red-300 (2 shades lighter than red-500) - dark: 'rgb(252 165 165)', // red-300 (2 shades lighter than red-400) - }, - it_ops: { - light: 'rgb(103 232 249)', // cyan-300 (2 shades lighter than cyan-500) - dark: 'rgb(103 232 249)', // cyan-300 (2 shades lighter than cyan-400) - }, - notification: { - light: 'rgb(249 168 212)', // pink-300 - dark: 'rgb(249 168 212)', // pink-300 - }, - manual_action: { - light: 'rgb(252 211 77)', // amber-300 (2 shades lighter than amber-500) - dark: 'rgb(252 211 77)', // amber-300 (2 shades lighter than amber-400) - }, - output: { - light: 'rgb(134 239 172)', // green-300 (2 shades lighter than green-500) - dark: 'rgb(134 239 172)', // green-300 (2 shades lighter than green-400) - }, - }; +function resolveCategory(category: string): ComponentCategory { + return normalizeComponentCategory(category) ?? DEFAULT_COMPONENT_CATEGORY; +} -/** - * Category-based header background colors (very light shades) - * Used for node headers and sidebar accordions - */ -export const CATEGORY_HEADER_BG_COLORS: Record = - { - input: { - light: 'rgb(250 252 255)', // custom blue-25 - dark: 'rgb(23 37 84 / 0.15)', // blue-950/15 - }, - transform: { - light: 'rgb(255 251 250)', // custom orange-25 - dark: 'rgb(69 10 10 / 0.15)', // orange-950/15 - }, - ai: { - light: 'rgb(253 250 255)', // custom violet-25 - dark: 'rgb(36 25 50 / 0.15)', // violet-950/15 - }, - mcp: { - light: 'rgb(247 254 253)', // teal-25 - dark: 'rgb(19 78 74 / 0.15)', // teal-950/15 - }, - security: { - light: 'rgb(255 250 250)', // custom red-25 - dark: 'rgb(69 10 10 / 0.15)', // red-950/15 - }, - it_ops: { - light: 'rgb(250 254 255)', // custom cyan-25 - dark: 'rgb(22 78 99 / 0.15)', // cyan-950/15 - }, - notification: { - light: 'rgb(255 250 253)', // pink-25 - dark: 'rgb(80 7 36 / 0.15)', // pink-950/15 - }, - manual_action: { - light: 'rgb(255 254 250)', // custom amber-25 - dark: 'rgb(120 53 15 / 0.15)', // amber-950/15 - }, - output: { - light: 'rgb(250 255 250)', // custom green-25 - dark: 'rgb(20 83 45 / 0.15)', // green-950/15 - }, - }; +export function getCategoryTextColorClass(category: string): string { + return getComponentCategoryDescriptor(resolveCategory(category)).textColorClass; +} -/** - * Get category separator color (for header separator lines) - * @param category - Component category - * @param isDarkMode - Whether dark mode is active - * @returns RGB color string or undefined - */ -export function getCategorySeparatorColor( - category: ComponentCategory, - isDarkMode: boolean, -): string | undefined { - const colors = CATEGORY_SEPARATOR_COLORS[category]; +export function getCategorySeparatorColor(category: string, isDarkMode: boolean): string { + const colors = getComponentCategoryDescriptor(resolveCategory(category)).separatorColor; return isDarkMode ? colors.dark : colors.light; } -/** - * Get category header background color - * @param category - Component category - * @param isDarkMode - Whether dark mode is active - * @returns RGB color string or undefined - */ -export function getCategoryHeaderBackgroundColor( - category: ComponentCategory, - isDarkMode: boolean, -): string | undefined { - const colors = CATEGORY_HEADER_BG_COLORS[category]; +export function getCategoryHeaderBackgroundColor(category: string, isDarkMode: boolean): string { + const colors = getComponentCategoryDescriptor(resolveCategory(category)).headerBackgroundColor; return isDarkMode ? colors.dark : colors.light; } -/** - * Hook to get category colors based on current theme - * @param category - Component category - * @returns Object with separatorColor and headerBackgroundColor - */ -export function useCategoryColors(category: ComponentCategory) { +export function useCategoryColors(category: string) { const theme = useThemeStore((state) => state.theme); const isDarkMode = theme === 'dark'; return { separatorColor: getCategorySeparatorColor(category, isDarkMode), headerBackgroundColor: getCategoryHeaderBackgroundColor(category, isDarkMode), + textColorClass: getCategoryTextColorClass(category), }; } diff --git a/packages/component-sdk/package.json b/packages/component-sdk/package.json index 53cdf766..9460f3c5 100644 --- a/packages/component-sdk/package.json +++ b/packages/component-sdk/package.json @@ -19,6 +19,7 @@ "test": "bun test" }, "dependencies": { + "@shipsec/shared": "workspace:*", "zod": "^4.3.6" }, "optionalDependencies": { diff --git a/packages/component-sdk/src/types.ts b/packages/component-sdk/src/types.ts index 571b3057..a01bf5d7 100644 --- a/packages/component-sdk/src/types.ts +++ b/packages/component-sdk/src/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { ComponentCategory } from '@shipsec/shared'; import type { IArtifactService, @@ -364,18 +365,6 @@ export interface ComponentAuthorMetadata { url?: string; } -// Categories supported by the new functional grouping plus legacy values for backwards compatibility -export type ComponentCategory = - | 'input' - | 'transform' - | 'ai' - | 'mcp' - | 'security' - | 'it_ops' - | 'notification' - | 'manual_action' - | 'output'; - export type ComponentUiType = | 'trigger' | 'input' diff --git a/packages/shared/src/component-categories.ts b/packages/shared/src/component-categories.ts new file mode 100644 index 00000000..fc674c15 --- /dev/null +++ b/packages/shared/src/component-categories.ts @@ -0,0 +1,274 @@ +export const COMPONENT_CATEGORIES = [ + 'input', + 'transform', + 'ai', + 'mcp', + 'security', + 'it_ops', + 'notification', + 'manual_action', + 'output', + 'process', + 'cloud', + 'core', +] as const; + +export type ComponentCategory = (typeof COMPONENT_CATEGORIES)[number]; + +export const DEFAULT_COMPONENT_CATEGORY: ComponentCategory = 'input'; + +export interface CategoryColorToken { + light: string; + dark: string; +} + +export interface ComponentCategoryDescriptor { + label: string; + description: string; + emoji: string; + icon: string; + color: string; + textColorClass: string; + separatorColor: CategoryColorToken; + headerBackgroundColor: CategoryColorToken; +} + +export const COMPONENT_CATEGORY_DESCRIPTORS: Record = { + input: { + label: 'Input', + description: 'Data sources, triggers, and credential access', + emoji: '📥', + icon: 'Download', + color: 'text-blue-600', + textColorClass: 'text-blue-600 dark:text-blue-400', + separatorColor: { + light: 'rgb(147 197 253)', + dark: 'rgb(147 197 253)', + }, + headerBackgroundColor: { + light: 'rgb(250 252 255)', + dark: 'rgb(23 37 84 / 0.15)', + }, + }, + transform: { + label: 'Transform', + description: 'Data processing, text manipulation, and formatting', + emoji: '🔄', + icon: 'RefreshCw', + color: 'text-orange-600', + textColorClass: 'text-orange-600 dark:text-orange-400', + separatorColor: { + light: 'rgb(253 186 116)', + dark: 'rgb(253 186 116)', + }, + headerBackgroundColor: { + light: 'rgb(255 251 250)', + dark: 'rgb(69 10 10 / 0.15)', + }, + }, + ai: { + label: 'AI Components', + description: 'AI-powered analysis and generation tools', + emoji: '🤖', + icon: 'Brain', + color: 'text-violet-600', + textColorClass: 'text-violet-600 dark:text-violet-400', + separatorColor: { + light: 'rgb(196 181 253)', + dark: 'rgb(196 181 253)', + }, + headerBackgroundColor: { + light: 'rgb(253 250 255)', + dark: 'rgb(36 25 50 / 0.15)', + }, + }, + mcp: { + label: 'MCP Servers', + description: 'Model Context Protocol servers and tool gateways', + emoji: '🔌', + icon: 'Plug', + color: 'text-teal-600', + textColorClass: 'text-teal-600 dark:text-teal-400', + separatorColor: { + light: 'rgb(153 246 228)', + dark: 'rgb(94 234 212)', + }, + headerBackgroundColor: { + light: 'rgb(247 254 253)', + dark: 'rgb(19 78 74 / 0.15)', + }, + }, + security: { + label: 'Security Tools', + description: 'Security scanning and assessment tools', + emoji: '🔒', + icon: 'Shield', + color: 'text-red-600', + textColorClass: 'text-red-600 dark:text-red-400', + separatorColor: { + light: 'rgb(252 165 165)', + dark: 'rgb(252 165 165)', + }, + headerBackgroundColor: { + light: 'rgb(255 250 250)', + dark: 'rgb(69 10 10 / 0.15)', + }, + }, + it_ops: { + label: 'IT Ops', + description: 'IT operations and user management workflows', + emoji: '🏢', + icon: 'Building', + color: 'text-cyan-600', + textColorClass: 'text-cyan-600 dark:text-cyan-400', + separatorColor: { + light: 'rgb(103 232 249)', + dark: 'rgb(103 232 249)', + }, + headerBackgroundColor: { + light: 'rgb(250 254 255)', + dark: 'rgb(22 78 99 / 0.15)', + }, + }, + notification: { + label: 'Notification', + description: 'Slack, Email, and other messaging alerts', + emoji: '🔔', + icon: 'Bell', + color: 'text-pink-600', + textColorClass: 'text-pink-600 dark:text-pink-400', + separatorColor: { + light: 'rgb(249 168 212)', + dark: 'rgb(249 168 212)', + }, + headerBackgroundColor: { + light: 'rgb(255 250 253)', + dark: 'rgb(80 7 36 / 0.15)', + }, + }, + manual_action: { + label: 'Manual Action', + description: 'Human-in-the-loop interactions, approvals, and manual tasks', + emoji: '👤', + icon: 'UserCheck', + color: 'text-amber-600', + textColorClass: 'text-amber-600 dark:text-amber-400', + separatorColor: { + light: 'rgb(252 211 77)', + dark: 'rgb(252 211 77)', + }, + headerBackgroundColor: { + light: 'rgb(255 254 250)', + dark: 'rgb(120 53 15 / 0.15)', + }, + }, + output: { + label: 'Output', + description: 'Data export, notifications, and integrations', + emoji: '📤', + icon: 'Upload', + color: 'text-green-600', + textColorClass: 'text-green-600 dark:text-green-400', + separatorColor: { + light: 'rgb(134 239 172)', + dark: 'rgb(134 239 172)', + }, + headerBackgroundColor: { + light: 'rgb(250 255 250)', + dark: 'rgb(20 83 45 / 0.15)', + }, + }, + process: { + label: 'Process', + description: 'Data processing and transformation steps', + emoji: '⚙️', + icon: 'Cog', + color: 'text-slate-600', + textColorClass: 'text-slate-600 dark:text-slate-400', + separatorColor: { + light: 'rgb(148 163 184)', + dark: 'rgb(148 163 184)', + }, + headerBackgroundColor: { + light: 'rgb(248 250 252)', + dark: 'rgb(30 41 59 / 0.2)', + }, + }, + cloud: { + label: 'Cloud', + description: 'Cloud provider integrations and services', + emoji: '☁️', + icon: 'Cloud', + color: 'text-sky-600', + textColorClass: 'text-sky-600 dark:text-sky-400', + separatorColor: { + light: 'rgb(125 211 252)', + dark: 'rgb(125 211 252)', + }, + headerBackgroundColor: { + light: 'rgb(240 249 255)', + dark: 'rgb(12 74 110 / 0.2)', + }, + }, + core: { + label: 'Core', + description: 'Core platform utilities and credential management', + emoji: '🔧', + icon: 'Wrench', + color: 'text-gray-600', + textColorClass: 'text-gray-600 dark:text-gray-400', + separatorColor: { + light: 'rgb(209 213 219)', + dark: 'rgb(156 163 175)', + }, + headerBackgroundColor: { + light: 'rgb(249 250 251)', + dark: 'rgb(31 41 55 / 0.2)', + }, + }, +}; + +export const COMPONENT_CATEGORY_ORDER: readonly ComponentCategory[] = [ + 'input', + 'core', + 'output', + 'notification', + 'security', + 'mcp', + 'cloud', + 'ai', + 'transform', + 'process', + 'it_ops', + 'manual_action', +]; + +export function isComponentCategory(value: string): value is ComponentCategory { + return (COMPONENT_CATEGORIES as readonly string[]).includes(value); +} + +export function normalizeComponentCategory(value?: string | null): ComponentCategory | null { + if (!value) { + return null; + } + + const normalized = value.trim().toLowerCase(); + return isComponentCategory(normalized) ? normalized : null; +} + +export function resolveComponentCategory(value?: string | null): ComponentCategory { + return normalizeComponentCategory(value) ?? DEFAULT_COMPONENT_CATEGORY; +} + +export function getComponentCategoryDescriptor(category?: string | null): ComponentCategoryDescriptor { + return COMPONENT_CATEGORY_DESCRIPTORS[resolveComponentCategory(category)]; +} + +export function compareComponentCategoryOrder(a: string, b: string): number { + const indexA = isComponentCategory(a) ? COMPONENT_CATEGORY_ORDER.indexOf(a) : -1; + const indexB = isComponentCategory(b) ? COMPONENT_CATEGORY_ORDER.indexOf(b) : -1; + if (indexA === -1 && indexB === -1) return 0; + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3ec49a32..cee4a930 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,6 @@ export * from './execution.js'; export * from './artifacts.js'; +export * from './component-categories.js'; export * from './secrets/encryption.js'; export * from './destinations.js'; export * from './schedules.js';