Skip to content

Generic Custom Entity Rendering - Polish UI for Schema-Driven Types #449

Description

@malibio

Parent Issue

Part of Epic #445: Custom Entity Types - User-Defined Schemas with Natural Language Creation

Problem

While custom entity types can be created and used after implementing #446 and #447, the rendering experience needs polish to ensure custom entities look professional and are easy to work with.

Current state (after #446 + #447):

  • Custom entity nodes can be created ✅
  • They render with BaseNode (basic content editing) ✅
  • SchemaPropertyForm exists but needs integration verification ✅
  • Generic icons used (text-circle fallback) ⚠️
  • No fallback UI for entities without schemas ❌
  • Unclear visual distinction from core types ⚠️

Desired state:

  • Custom entities render cleanly with full property support ✅
  • Missing schemas show helpful error state ✅
  • Visual styling distinguishes custom from core types ✅
  • Icons can be customized per entity type ✅
  • Performance is equivalent to core types ✅

Solution

Polish the generic custom entity rendering experience with focus on:

  1. Component Integration - Ensure BaseNode + SchemaPropertyForm work seamlessly
  2. Error Handling - Graceful fallbacks for missing schemas
  3. Visual Polish - Clear distinction and professional appearance
  4. Icon System - Support for custom icons (optional metadata field)
  5. Performance - No degradation vs. core types

Architecture

Component Hierarchy:

CustomEntityNode (wrapper)
  ├─ BaseNode (content editing)
  └─ SchemaPropertyForm (property editing)

Fallback Chain:

1. Try custom component (if registered)
2. Fall back to CustomEntityNode
3. If no schema: Show error state
4. If schema exists: Render with SchemaPropertyForm

Implementation Details

1. Custom Entity Node Component

File: packages/desktop-app/src/lib/design/components/custom-entity-node.svelte

<script lang="ts">
  import BaseNode from './base-node.svelte';
  import SchemaPropertyForm from '$lib/components/property-forms/schema-property-form.svelte';
  import { schemaService } from '$lib/services/schema-service';
  import type { Node } from '$lib/types';
  
  let { 
    nodeId,
    nodeType,
    content = '',
    properties = {},
    metadata = {}
  }: {
    nodeId: string;
    nodeType: string;
    content?: string;
    properties?: Record<string, unknown>;
    metadata?: Record<string, unknown>;
  } = $props();
  
  // Load schema for this entity type
  let schema = $state(null);
  let schemaError = $state<string | null>(null);
  let isLoadingSchema = $state(true);
  
  $effect(() => {
    async function loadSchema() {
      isLoadingSchema = true;
      schemaError = null;
      
      try {
        schema = await schemaService.getSchema(nodeType);
      } catch (error) {
        console.error(`[CustomEntityNode] Failed to load schema for ${nodeType}:`, error);
        schemaError = error instanceof Error ? error.message : 'Failed to load schema';
        schema = null;
      } finally {
        isLoadingSchema = false;
      }
    }
    
    loadSchema();
  });
  
  // Get custom icon from schema metadata (if available)
  const customIcon = $derived(schema?.properties?.icon || null);
  const entityName = $derived(schema?.content || nodeType);
</script>

<div class="custom-entity-node" data-entity-type={nodeType}>
  <!-- Entity Header (optional, shows entity type name) -->
  {#if schema && schema.properties?.show_entity_header !== false}
    <div class="entity-header">
      {#if customIcon}
        <span class="entity-icon">{customIcon}</span>
      {/if}
      <span class="entity-type-name">{entityName}</span>
    </div>
  {/if}
  
  <!-- Base Content Editing -->
  <BaseNode 
    {nodeId} 
    {nodeType} 
    bind:content 
    {properties}
    {metadata}
  />
  
  <!-- Schema Properties (if schema exists) -->
  {#if isLoadingSchema}
    <div class="schema-loading">
      <span class="text-sm text-muted-foreground">Loading properties...</span>
    </div>
  {:else if schemaError}
    <div class="schema-error">
      <span class="text-sm text-destructive">
        ⚠️ Schema not found for "{nodeType}". 
        <button 
          class="create-schema-link"
          onclick={() => window.createSchemaForType?.(nodeType)}
        >
          Create schema
        </button>
      </span>
    </div>
  {:else if schema}
    <SchemaPropertyForm {nodeId} {nodeType} />
  {/if}
</div>

<style>
  .custom-entity-node {
    border-left: 3px solid var(--custom-entity-accent, #6366f1);
    padding-left: 0.75rem;
  }
  
  .entity-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
    padding: 0.25rem 0.5rem;
    background: var(--muted);
    border-radius: 0.25rem;
    font-size: 0.875rem;
    font-weight: 500;
  }
  
  .entity-icon {
    font-size: 1rem;
  }
  
  .entity-type-name {
    color: var(--muted-foreground);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-size: 0.75rem;
  }
  
  .schema-loading,
  .schema-error {
    padding: 0.5rem;
    text-align: center;
  }
  
  .schema-error {
    background: var(--destructive-foreground);
    border-radius: 0.25rem;
    margin-top: 0.5rem;
  }
  
  .create-schema-link {
    color: var(--primary);
    text-decoration: underline;
    background: none;
    border: none;
    cursor: pointer;
    padding: 0;
    font: inherit;
  }
</style>

2. Integration with Plugin Registry

Update schema-plugin-loader.ts (from #447) to register CustomEntityNode:

export function createPluginFromSchema(
  schema: SchemaDefinition,
  schemaId: string
): PluginDefinition {
  return {
    id: schemaId,
    name: schema.content || schemaId,
    config: {
      slashCommands: [...],
      canHaveChildren: true,
      canBeChild: true
    },
    node: {
      // Use custom entity component
      component: CustomEntityNode,  // ← Direct reference (not lazy)
      // OR lazy load:
      // lazyLoad: () => import('../design/components/custom-entity-node.svelte')
    }
  };
}

3. Icon System (Schema Metadata)

Allow schemas to specify custom icons:

{
  "id": "invoice",
  "node_type": "schema",
  "properties": {
    "is_core": false,
    "icon": "💰",  // Emoji or icon identifier
    "show_entity_header": true,  // Show entity type badge
    "description": "Invoice tracking",
    "fields": [...]
  }
}

Update slash command dropdown to use schema icons:

// In slash-command-dropdown.svelte
function getCommandIcon(command: SlashCommand): string {
  // Try to get icon from schema
  const schema = schemaService.getCachedSchema(command.nodeType);
  if (schema?.properties?.icon) {
    return schema.properties.icon;
  }
  
  // Fall back to hardcoded core icons or generic
  return getDefaultIcon(command.id);
}

4. Error State Handling

Missing Schema Fallback:

{#if schemaError}
  <div class="missing-schema-state">
    <h3>Schema Not Found</h3>
    <p>The "{nodeType}" entity type doesn't have a schema definition.</p>
    
    <div class="actions">
      <button onclick={() => createSchemaInteractive(nodeType)}>
        Create Schema
      </button>
      <button onclick={() => convertToTextNode(nodeId)}>
        Convert to Text Node
      </button>
    </div>
  </div>
{/if}

5. Visual Distinction

Styling Variables (CSS custom properties):

:root {
  --custom-entity-accent: #6366f1;  /* Indigo for custom entities */
  --core-entity-accent: #10b981;    /* Green for core types */
}

.custom-entity-node {
  border-left-color: var(--custom-entity-accent);
}

.core-entity-node {
  border-left-color: var(--core-entity-accent);
}

Badge/Label (entity header):

  • Shows entity type name in small uppercase text
  • Optional icon (emoji or SVG)
  • Subtle background color
  • Collapsible (user preference)

Acceptance Criteria

Functional Requirements

  • CustomEntityNode component renders all custom entity types
  • BaseNode integration works seamlessly
  • SchemaPropertyForm displays and edits properties
  • Missing schemas show helpful error state
  • Users can create schema from error state
  • Icon system supports custom icons via schema metadata

Visual Requirements

  • Custom entities have distinct visual styling (border, badge)
  • Entity type name displayed in header (if enabled)
  • Custom icons render correctly (emoji and SVG)
  • Loading states show appropriate feedback
  • Error states are clear and actionable
  • Consistent with design system aesthetics

Performance Requirements

  • Render time <50ms (same as core types)
  • Schema loading cached (no repeated fetches)
  • No layout shifts during schema load
  • Memory usage <5MB for 100 custom entity nodes

UX Requirements

  • Clear visual distinction from core types
  • Helpful error messages for missing schemas
  • One-click schema creation from error state
  • Entity header can be toggled in preferences
  • Icon picker UI (future enhancement)

Testing Plan

Unit Tests

describe('CustomEntityNode', () => {
  it('renders with schema', async () => {
    const { container } = render(CustomEntityNode, {
      props: {
        nodeId: 'test-node',
        nodeType: 'invoice',
        content: 'Invoice #001'
      }
    });
    
    await waitFor(() => {
      expect(container.querySelector('.entity-header')).toBeTruthy();
      expect(container.querySelector('[data-entity-type="invoice"]')).toBeTruthy();
    });
  });
  
  it('shows error state for missing schema', async () => {
    // Mock schemaService to return error
    vi.spyOn(schemaService, 'getSchema').mockRejectedValue(new Error('Not found'));
    
    const { container } = render(CustomEntityNode, {
      props: {
        nodeId: 'test-node',
        nodeType: 'nonexistent',
        content: 'Test'
      }
    });
    
    await waitFor(() => {
      expect(container.textContent).toContain('Schema not found');
    });
  });
  
  it('renders custom icon from schema', async () => {
    const schema = {
      properties: { icon: '💰' }
    };
    vi.spyOn(schemaService, 'getSchema').mockResolvedValue(schema);
    
    const { container } = render(CustomEntityNode, {
      props: {
        nodeId: 'test-node',
        nodeType: 'invoice',
        content: 'Invoice'
      }
    });
    
    await waitFor(() => {
      expect(container.querySelector('.entity-icon').textContent).toBe('💰');
    });
  });
});

Integration Tests

describe('Custom entity rendering integration', () => {
  it('creates custom entity and renders with properties', async () => {
    // Create schema
    await schemaService.createSchema('invoice', [
      { name: 'custom:amount', type: 'number' }
    ]);
    
    // Create node
    const node = await nodeService.createNode({
      nodeType: 'invoice',
      content: 'Invoice #001',
      properties: { invoice: { amount: 1500 } }
    });
    
    // Render
    const { container } = render(CustomEntityNode, {
      props: { nodeId: node.id, nodeType: 'invoice', ...node }
    });
    
    // Verify property form appears
    await waitFor(() => {
      expect(container.querySelector('[data-testid="schema-property-form"]')).toBeTruthy();
    });
  });
});

Visual Regression Tests

  • Screenshot comparison for custom entity rendering
  • Icon display verification
  • Error state appearance
  • Loading state appearance

Dependencies

Performance Considerations

  • Schema caching: Cache loaded schemas to avoid repeated fetches
  • Component lazy loading: CustomEntityNode can be lazy-loaded per entity type
  • Render optimization: Use Svelte reactivity best practices

Benchmark targets:

  • First render: <50ms
  • Schema load: <20ms (cached), <100ms (uncached)
  • Property updates: <10ms

Migration Notes

No migration required - This is additive UI functionality.

Deployment notes:

Documentation Updates

  • Add CustomEntityNode component documentation
  • Document icon system and schema metadata
  • Add visual styling guide for custom entities
  • Document error handling and fallback states
  • Add examples of custom entity UI customization

Future Enhancements (Out of Scope)

  • Custom component registration per entity type (specialized UI)
  • Icon picker UI in schema editor
  • Entity type templates library
  • Visual schema editor
  • Property reordering and drag-drop

Success Metrics

  • Custom entities render as fast as core types (<50ms)
  • Zero layout shifts during schema loading
  • <2% user-reported rendering issues
  • 90%+ user satisfaction with custom entity appearance

Labels: enhancement, ui, frontend, svelte, design-system
Priority: Medium (polish, not blocking)
Estimated effort: 6-8 hours

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions