Эх сурвалжийг харах

fix: draw every node's icon, not the four the canvas knew about

The canvas spelled out four icons by hand - play, globe, git-branch and
repeat - and drew nothing at all for the rest. The fallback that shows a
node's initial only fired when a node had NO icon, so a node with an icon
the list did not know about got an empty box, which is worse than the
initial it would have had by declaring nothing.

The editor already had a resolver for this, used by the node picker, so
the canvas now uses it too.

That resolver had the same problem one level down: a hand-written table of
name to component that had fallen thirty-six names behind the fifty-six
the nodes ask for. Everything missing quietly became the default icon.
The names are Lucide's own, so they are resolved from the name instead -
kebab-case to PascalCase, with a two-entry alias table for the ones Lucide
spells differently. Nothing is saved by listing them: the whole of Lucide
is imported by that file regardless.

scripts/check-node-icons.js reads every @icon in nodes/ and fails if one
cannot be resolved, naming the node. An unresolvable name does not break
anything - it silently becomes the default icon - so without this the only
way to notice is to look at every node on a canvas.

Also gives utils-test the icon it never had, so all 84 nodes now declare
one.

Verified in the browser: rss-reader, database-change, utils-test, filter
and merge all draw their icon on the canvas where four of the five drew
nothing before, and a node whose type does not exist still falls back to
its initial.
fszontagh 1 сар өмнө
parent
commit
a805788468

BIN
node-picker-icons.png


+ 1 - 0
nodes/test/utils-test.js

@@ -4,6 +4,7 @@
  * @description Test node for smartbotic.utils API
  * @category development
  * @version 1.0.0
+ * @icon flask-conical
  * @trigger false
  */
 

+ 92 - 0
scripts/check-node-icons.js

@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * Checks that every icon a node asks for is one Lucide actually has.
+ *
+ * Nodes declare an icon with `@icon <name>` and the editor resolves the name to
+ * a Lucide component. A name Lucide does not have does not fail anything - it
+ * quietly becomes the default icon - so the only way to notice is to look at
+ * every node on a canvas. This looks instead:
+ *
+ *   node scripts/gen-node-icons.js
+ *
+ * Exits non-zero and names the nodes if any icon cannot be resolved, so it can
+ * go in a pre-commit hook or CI.
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const ROOT = path.resolve(__dirname, '..')
+const NODES_DIR = path.join(ROOT, 'nodes')
+
+// Kept in step with ICON_ALIASES in webui/src/config/nodeCategories.ts - this
+// script has no way to read the TypeScript one.
+const ALIASES = {
+  images: 'GalleryHorizontalEnd',
+  'octagon-x': 'XOctagon',
+}
+
+function pascal(name) {
+  return name.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : '')).join('')
+}
+
+function collectNodeFiles(dir) {
+  const out = []
+  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+    const full = path.join(dir, entry.name)
+    if (entry.isDirectory()) out.push(...collectNodeFiles(full))
+    else if (entry.name.endsWith('.js')) out.push(full)
+  }
+  return out
+}
+
+function iconsInUse() {
+  const found = new Map() // icon name -> [node ids]
+  for (const file of collectNodeFiles(NODES_DIR)) {
+    const code = fs.readFileSync(file, 'utf8')
+    const icon = code.match(/@icon\s+([a-z0-9-]+)/i)
+    const id = code.match(/@node\s+([a-z0-9-]+)/i)
+    if (!icon) continue
+    const name = icon[1].toLowerCase()
+    if (!found.has(name)) found.set(name, [])
+    found.get(name).push(id ? id[1] : path.basename(file))
+  }
+  return found
+}
+
+function main() {
+  const inUse = iconsInUse()
+
+  let lucide
+  try {
+    lucide = require(path.join(ROOT, 'webui/node_modules/lucide-react'))
+  } catch (e) {
+    console.error('Could not load lucide-react from webui/node_modules - run npm install first')
+    process.exit(2)
+  }
+
+  const unresolved = []
+  for (const name of [...inUse.keys()].sort()) {
+    const component = ALIASES[name] || pascal(name)
+    if (!lucide[component]) unresolved.push([name, component, inUse.get(name)])
+  }
+
+  if (unresolved.length) {
+    console.error('These icon names do not exist in lucide-react:')
+    for (const [name, component, nodes] of unresolved) {
+      console.error(`  @icon ${name} (tried ${component}) - used by ${nodes.join(', ')}`)
+    }
+    console.error('\nAdd an alias in webui/src/config/nodeCategories.ts, or change the')
+    console.error('node to a name Lucide has: https://lucide.dev/icons')
+    process.exit(1)
+  }
+
+  const files = collectNodeFiles(NODES_DIR)
+  const withIcon = [...inUse.values()].reduce((n, ids) => n + ids.length, 0)
+  console.log(`${inUse.size} distinct icons across ${withIcon} nodes - all resolve`)
+  if (withIcon < files.length) {
+    console.log(`${files.length - withIcon} node file(s) declare no @icon and will show their initial`)
+  }
+}
+
+main()

+ 10 - 5
webui/src/components/workflow/WorkflowNode.tsx

@@ -1,5 +1,6 @@
 import { Handle, Position, NodeProps } from 'reactflow'
 import { Play, CheckCircle, XCircle, Loader2 } from 'lucide-react'
+import { getIconComponent } from '../../config/nodeCategories'
 import { NodeOutput, DynamicOutputs } from '../../api/workflows'
 
 export interface NodeExecutionState {
@@ -52,6 +53,7 @@ export function WorkflowNode({ data, selected }: NodeProps) {
   const isTrigger = data.isTrigger
   const disabled = data.disabled === true
   const lockedBy = data.lockedBy as { username: string; kind: string } | undefined
+  const NodeIcon = getIconComponent(data.icon)
   const execState = data.executionState as NodeExecutionState | undefined
   const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
   const inputs: NodeOutput[] = data.inputs?.length
@@ -204,11 +206,14 @@ export function WorkflowNode({ data, selected }: NodeProps) {
         <div className={`w-8 h-8 rounded flex items-center justify-center ${
           isTrigger ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-400'
         }`}>
-          {data.icon === 'play' && <Play className="w-4 h-4" />}
-          {data.icon === 'globe' && <span className="text-sm">🌐</span>}
-          {data.icon === 'git-branch' && <span className="text-sm">⑂</span>}
-          {data.icon === 'repeat' && <span className="text-sm">🔁</span>}
-          {!data.icon && <span className="text-xs font-medium">{(data.config?._customLabel || data.label)?.charAt(0) || '?'}</span>}
+          {/* Four icons used to be spelled out here and everything else drew
+              nothing at all - the letter fallback only fired when a node had no
+              icon, so a node WITH an icon this list did not know about got an
+              empty box. The same resolver the rest of the editor uses answers
+              for all of them, and falls back to the initial. */}
+          {data.icon
+            ? <NodeIcon className="w-4 h-4" />
+            : <span className="text-xs font-medium">{(data.config?._customLabel || data.label)?.charAt(0) || '?'}</span>}
         </div>
 
         <div className="flex-1 min-w-0">

+ 25 - 2
webui/src/config/nodeCategories.ts

@@ -98,10 +98,33 @@ function formatCategoryName(category: string): string {
     .join(' ')
 }
 
-// Get icon component from icon name string
+// A few names Lucide does not have under the obvious spelling. Small and
+// explicit on purpose: a wrong guess here is a wrong icon, which is worse than
+// no icon.
+const ICON_ALIASES: Record<string, string> = {
+  'images': 'GalleryHorizontalEnd',
+  'octagon-x': 'XOctagon',
+}
+
+/**
+ * The Lucide component a node's `@icon` name refers to.
+ *
+ * Resolved from the name rather than looked up in a hand-written table. The
+ * table had fallen thirty-six names behind the nodes, and being behind is
+ * invisible: an unlisted name silently became the default icon, or on the
+ * canvas an empty box. Nothing is saved by listing them either - the whole of
+ * Lucide is imported above regardless.
+ */
 export function getIconComponent(iconName: string | undefined): LucideIcon {
   if (!iconName) return DEFAULT_ICON
-  return ICON_MAP[iconName.toLowerCase()] || DEFAULT_ICON
+  const name = iconName.toLowerCase()
+
+  const component = ICON_ALIASES[name] ||
+    name.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : '')).join('')
+
+  return ((LucideIcons as unknown as Record<string, LucideIcon>)[component]) ||
+    ICON_MAP[name] ||
+    DEFAULT_ICON
 }
 
 export interface CategoryConfig {