Files
OpenFlare/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md
T
ryan b5e27fabde feat(waf): 规则编辑器节点自定义命名与拖放添加
对齐后端 label 字段;属性栏可编辑显示名称;节点库改为拖到画布落点创建。
2026-07-19 11:01:24 +08:00

11 KiB
Raw Blame History

WAF Editor Node Label + Drag-Add Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let users rename WAF rule nodes via optional label, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).

Architecture: Frontend-only. Align TS WAFRuleNode with backend label. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with screenToFlowPosition. Properties panel edits label for non-system nodes.

Tech Stack: Next.js App Router, React, TypeScript, @xyflow/react, Vitest + Testing Library, shadcn/ui.

Spec: docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md

Global Constraints

  • No backend / schema_version / note field changes.
  • System nodes start / allow: no rename UI.
  • New nodes: no default label (type name shown).
  • Drag-only add; remove click-add.
  • After code: relevant vitest pass; run make prettier / make code-check if touching repo gates.

File Map

File Role
frontend/lib/services/openflare/types.ts Add label?: string to all WAFRuleNode variants
frontend/app/(main)/waf/rules/editor/components/node-factory.ts NODE_TYPE_LABELS, displayNodeTitle, createRuleNode, drag MIME constant
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts Unit tests for title + factory
frontend/app/(main)/waf/rules/editor/components/rule-node.tsx Use displayNodeTitle
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx 「显示名称」Input
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx Label edit + system node
frontend/app/(main)/waf/rules/editor/components/node-library.tsx Draggable items, no onClick
frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx Drop handler + position-aware create

Task 1: Types + pure helpers

Files:

  • Modify: frontend/lib/services/openflare/types.ts
  • Create: frontend/app/(main)/waf/rules/editor/components/node-factory.ts
  • Create: frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts

Interfaces:

  • Produces: WAF_NODE_DRAG_MIME, AddableNodeType, NODE_TYPE_LABELS, displayNodeTitle(node), createRuleNode(type, position)

  • Step 1: Add label?: string to every WAFRuleNode union member in types.ts.

  • Step 2: Write failing tests in node-factory.test.ts:

import { describe, expect, it } from 'vitest';
import {
  createRuleNode,
  displayNodeTitle,
  NODE_TYPE_LABELS,
} from './node-factory';

describe('displayNodeTitle', () => {
  it('uses trimmed label when present', () => {
    expect(
      displayNodeTitle({
        id: 'x',
        type: 'ip_match',
        label: '  办公室  ',
        position: { x: 0, y: 0 },
        config: { ips: [], cidrs: [], ip_group_ids: [] },
      }),
    ).toBe('办公室');
  });

  it('falls back to type default when label empty', () => {
    expect(
      displayNodeTitle({
        id: 'x',
        type: 'block',
        label: '  ',
        position: { x: 0, y: 0 },
        config: { status_code: 403, response_body: '' },
      }),
    ).toBe(NODE_TYPE_LABELS.block);
  });
});

describe('createRuleNode', () => {
  it('creates typed node at position without label', () => {
    const node = createRuleNode('pow', { x: 12, y: 34 });
    expect(node.type).toBe('pow');
    expect(node.position).toEqual({ x: 12, y: 34 });
    expect(node.label).toBeUndefined();
    expect(node.id.startsWith('pow-')).toBe(true);
    if (node.type === 'pow') {
      expect(node.config).toEqual({
        algorithm: 'fast',
        difficulty: 4,
        session_ttl: 3600,
        challenge_ttl: 300,
      });
    }
  });
});
  • Step 3: Implement node-factory.ts
import type { WAFRuleNode } from '@/lib/services/openflare';

export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';

export type AddableNodeType = Extract<
  WAFRuleNode['type'],
  'ip_match' | 'geo_match' | 'pow' | 'block'
>;

export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
  start: '开始',
  ip_match: 'IP 匹配',
  geo_match: '地域匹配',
  pow: 'PoW 挑战',
  allow: '通过',
  block: '阻止',
};

export function displayNodeTitle(
  node: Pick<WAFRuleNode, 'type' | 'label'>,
): string {
  const custom = node.label?.trim();
  return custom || NODE_TYPE_LABELS[node.type];
}

export function createRuleNode(
  type: AddableNodeType,
  position: { x: number; y: number },
): WAFRuleNode {
  const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
  if (type === 'ip_match')
    return {
      id,
      type,
      position,
      config: { ips: [], cidrs: [], ip_group_ids: [] },
    };
  if (type === 'geo_match')
    return { id, type, position, config: { countries: [], regions: [] } };
  if (type === 'pow')
    return {
      id,
      type,
      position,
      config: {
        algorithm: 'fast',
        difficulty: 4,
        session_ttl: 3600,
        challenge_ttl: 300,
      },
    };
  return {
    id,
    type: 'block',
    position,
    config: { status_code: 403, response_body: '' },
  };
}

export function parseAddableNodeType(value: string): AddableNodeType | null {
  if (
    value === 'ip_match' ||
    value === 'geo_match' ||
    value === 'pow' ||
    value === 'block'
  )
    return value;
  return null;
}
  • Step 4: Run tests
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'

Expected: PASS

  • Step 5: Commit
git add frontend/lib/services/openflare/types.ts \
  frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
  frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
git commit -m "feat(waf): add node label type and factory helpers"

Task 2: Canvas title + properties label field

Files:

  • Modify: frontend/app/(main)/waf/rules/editor/components/rule-node.tsx

  • Modify: frontend/app/(main)/waf/rules/editor/components/node-properties.tsx

  • Modify: frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx

  • Step 1: Tests for properties

Add to node-properties.test.tsx:

it('edits display name for configurable nodes', () => {
  const node: WAFRuleNode = {
    id: 'match',
    type: 'ip_match',
    position: { x: 0, y: 0 },
    config: { ips: [], cidrs: [], ip_group_ids: [] },
  };
  const onChange = vi.fn();
  render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
  fireEvent.change(screen.getByLabelText('显示名称'), {
    target: { value: '内网放行' },
  });
  expect(onChange).toHaveBeenCalledWith(
    expect.objectContaining({ label: '内网放行' }),
  );
});

it('hides display name for system nodes', () => {
  const node: WAFRuleNode = {
    id: 'start',
    type: 'start',
    position: { x: 0, y: 0 },
    config: {},
  };
  render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
  expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
  expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
});
  • Step 2: Implement properties field — at start of each configurable FieldGroup (or wrap once before type switch for non-system):

Prefer extract:

function DisplayNameField({
  node,
  onChange,
}: {
  node: WAFRuleNode;
  onChange: (node: WAFRuleNode) => void;
}) {
  return (
    <Field>
      <FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
      <Input
        id={`${node.id}-label`}
        value={node.label ?? ''}
        placeholder={/* type default from NODE_TYPE_LABELS */}
        onChange={(e) => onChange({ ...node, label: e.target.value })}
      />
    </Field>
  );
}

Insert <DisplayNameField ... /> as first child inside each non-system FieldGroup.

  • Step 3: rule-node.tsx — use displayNodeTitle(rule) for main title; keep icon from meta; keep id subtitle.

  • Step 4: Run tests

cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'

Expected: PASS

  • Step 5: Commit
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
  frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
  frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
git commit -m "feat(waf): show and edit rule node display names"

Task 3: Drag library + canvas drop

Files:

  • Modify: frontend/app/(main)/waf/rules/editor/components/node-library.tsx

  • Modify: frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx

  • Create (optional pure tests): extend node-factory.test.ts for parseAddableNodeType

  • Step 1: Node library — remove onAdd prop; make each button draggable with:

onDragStart={(e) => {
  e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
  e.dataTransfer.setData('text/plain', type);
  e.dataTransfer.effectAllowed = 'copy';
}}

Use type='button' + cursor cursor-grab active:cursor-grabbing. No onClick that adds nodes.

  • Step 2: Canvas — replace addNode(type) fixed position with:
const addNodeAt = useCallback(
  (type: AddableNodeType, position: { x: number; y: number }) => {
    const node = createRuleNode(type, position);
    onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
    onSelectEdge(undefined);
    onSelect(node.id);
  },
  [graph, onGraphChange, onSelect, onSelectEdge],
);

const onDragOver = useCallback((e: React.DragEvent) => {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'copy';
}, []);

const onDrop = useCallback(
  (e: React.DragEvent) => {
    e.preventDefault();
    const raw =
      e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
      e.dataTransfer.getData('text/plain');
    const type = parseAddableNodeType(raw);
    if (!type || !instance.current) return;
    const position = instance.current.screenToFlowPosition({
      x: e.clientX,
      y: e.clientY,
    });
    addNodeAt(type, position);
  },
  [addNodeAt],
);

Pass onDragOver / onDrop to <ReactFlow ...> (xyflow supports these on the component).

Update <NodeLibrary /> — no onAdd.

  • Step 3: Run editor-related tests
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'

Expected: PASS (update any tests that assumed click-add)

  • Step 4: Format + commit
make prettier
git add frontend/app/(main)/waf/rules/editor
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
  • Step 5: Changelog — under docs/changelog/index.md [Unreleased]:
### 改进
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
git add docs/changelog/index.md
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"

Spec coverage

Spec item Task
label? on TS types 1
Display title fallback 1–2
Properties 显示名称 2
System nodes no rename 2
Drag-only library 3
Drop at cursor 3
No note / backend N/A (omitted)
Tests 1–3
Changelog 3