Skip to content

Latest commit

 

History

History
484 lines (386 loc) · 14.1 KB

File metadata and controls

484 lines (386 loc) · 14.1 KB
title Custom Plugin Development

This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading.

What Is an ObjectUI Plugin?

A plugin is a self-contained package that registers one or more components into the Component Registry. When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically.

Plugins differ from regular components in two ways:

  • Lazy-loaded — heavy dependencies are code-split and fetched on demand.
  • Self-registering — importing the package is enough; no manual wiring required.

Official plugins (@object-ui/plugin-grid, @object-ui/plugin-kanban, @object-ui/plugin-charts, etc.) all follow this pattern, and your custom plugins should too.

Plugin Anatomy

Every plugin has three key parts:

packages/plugin-board/
├── src/
│   ├── index.tsx          # Entry point: lazy wrapper + ComponentRegistry.register()
│   ├── BoardImpl.tsx      # Heavy implementation (imported lazily)
│   ├── BoardImpl.test.tsx # Tests
│   └── types.ts           # TypeScript interfaces & schema types
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md
File Role
index.tsx Lightweight entry — sets up React.lazy(), Suspense fallback, and calls ComponentRegistry.register().
BoardImpl.tsx The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle.
types.ts Schema interfaces extending BaseSchema from @object-ui/types.

Scaffolding With the CLI

The fastest way to start is the create-plugin generator:

npx @object-ui/create-plugin board --description "Kanban-style board view"

# Or with pnpm / npm create aliases:
pnpm create @object-ui/plugin board
npm create @object-ui/plugin board

This produces a ready-to-build plugin under packages/plugin-board/ with the correct package.json, Vite config, test file, and registry call already in place.

After scaffolding, install dependencies:

pnpm install

Implementing a Custom View Plugin

Let's build a board view plugin that renders items in columns (similar to a Kanban but simplified).

1. Define the Schema Types

// src/types.ts
import type { BaseSchema } from '@object-ui/types';

export interface BoardColumn {
  id: string;
  title: string;
}

export interface BoardItem {
  id: string;
  columnId: string;
  title: string;
  description?: string;
}

export interface BoardSchema extends BaseSchema {
  type: 'board';
  columns: BoardColumn[];
  items: BoardItem[];
  onItemMove?: (itemId: string, toColumnId: string) => void;
}

export interface BoardProps {
  schema: BoardSchema;
  className?: string;
}

2. Build the Implementation

// src/BoardImpl.tsx
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
import { cn } from '@object-ui/components';
import type { BoardProps } from './types';

export default function BoardImpl({ schema, className }: BoardProps) {
  const { columns, items } = schema;

  return (
    <div className={cn('grid gap-4', className)} style={{ gridTemplateColumns: `repeat(${columns.length}, 1fr)` }}>
      {columns.map((col) => (
        <div key={col.id} className="flex flex-col gap-2">
          <h3 className="text-sm font-semibold text-muted-foreground">{col.title}</h3>
          {items
            .filter((item) => item.columnId === col.id)
            .map((item) => (
              <Card key={item.id}>
                <CardHeader className="p-3">
                  <CardTitle className="text-sm">{item.title}</CardTitle>
                </CardHeader>
                {item.description && (
                  <CardContent className="p-3 pt-0 text-xs text-muted-foreground">
                    {item.description}
                  </CardContent>
                )}
              </Card>
            ))}
        </div>
      ))}
    </div>
  );
}

3. Create the Entry Point

// src/index.tsx
import React, { Suspense } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { Skeleton } from '@object-ui/components';

const LazyBoard = React.lazy(() => import('./BoardImpl'));

export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({
  schema,
  ...props
}) => (
  <Suspense fallback={<Skeleton className="w-full h-[300px]" />}>
    <LazyBoard schema={schema} {...props} />
  </Suspense>
);

// Auto-register on import
ComponentRegistry.register('board', BoardRenderer, {
  namespace: 'plugin-board',
  label: 'Board View',
  category: 'plugin',
  inputs: [
    { name: 'columns', type: 'array', label: 'Columns', required: true },
    { name: 'items', type: 'array', label: 'Items', required: true },
  ],
  defaultProps: {
    columns: [
      { id: 'todo', title: 'To Do' },
      { id: 'done', title: 'Done' },
    ],
    items: [],
  },
});

export { default as BoardImpl } from './BoardImpl';
export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types';

Now any schema with "type": "board" will resolve to your component.

Implementing a Custom Field Widget

Field widgets follow the FieldWidgetComponentProps interface from @object-ui/fields.

// FieldWidgetComponentProps<T> shape (from packages/fields/src/widgets/types.ts)
type FieldWidgetComponentProps<T = any> = {
  value: T;
  onChange: (val: T) => void;
  field: FieldMetadata;
  readonly?: boolean;
  disabled?: boolean;
  className?: string;
  error?: string;
};

The validation slot is named error, matching FieldWidgetPropsSchema in @objectstack/spec/ui — the published contract a widget is written against. The form renderer supplies it from the active validation message.

field is the only metadata carrier (v17, breaking)

Before v17 a widget could receive its metadata under either field or schema, depending on which host rendered it, so widgets written in that era resolve their config as field || schema. schema has been removed from FieldWidgetComponentProps in v17: read props.field, full stop. Reading props.schema now yields undefined.

schema itself is not going anywhere — it is the universal SDUI node SchemaRenderer hands to every registered component (element:*, page:*, grids, reports). That is precisely why a field widget needs an adapter when it is rendered from a schema node instead of from a form. Wrap it once, at registration, and the widget only ever implements one contract:

import { ComponentRegistry } from '@object-ui/core';
import { withFieldCarrier } from '@object-ui/fields';

ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), {
  namespace: 'field',
});

withFieldCarrier forwards the node by reference (nothing is copied or dropped) and consumes schema so it never reaches the DOM. Every built-in field widget is registered through it.

Who renders what

The widget and the form renderer split validation display, and the split is not optional:

Concern Owner
aria-invalid on the input the widget — only it renders the input element
the required marker (*) the form renderer (<FormLabel>)
the message TEXT the form renderer (<FormMessage/>)

So consume error as a boolean signalaria-invalid={!!error} — and do not render the message yourself. The form already prints it below the control; a widget that prints it too shows the user the same sentence twice. For the same reason required is not in the props: the marker has one author.

Example: Color Picker Field

// src/ColorPickerField.tsx
import React from 'react';
import { Input } from '@object-ui/components';
import type { FieldWidgetComponentProps } from '@object-ui/fields';

export function ColorPickerField({
  value,
  onChange,
  field,
  readonly,
  disabled,
  error,
}: FieldWidgetComponentProps<string>) {
  if (readonly) {
    return (
      <div className="flex items-center gap-2">
        <span
          className="inline-block h-4 w-4 rounded-full border"
          style={{ backgroundColor: value || '#000' }}
        />
        <span className="text-sm">{value || '—'}</span>
      </div>
    );
  }

  return (
    <div className="flex items-center gap-2">
      <input
        type="color"
        value={value || '#000000'}
        onChange={(e) => onChange(e.target.value)}
        disabled={disabled}
        className="h-8 w-8 cursor-pointer rounded border-0 p-0"
      />
      <Input
        value={value || ''}
        onChange={(e) => onChange(e.target.value)}
        placeholder={field?.placeholder || '#000000'}
        disabled={disabled}
        className="font-mono text-sm"
        // The whole job of `error` here: tell assistive tech the field failed.
        // The message text is rendered by the form, not by this widget.
        aria-invalid={!!error}
      />
    </div>
  );
}

Register it as a field widget:

// src/index.tsx
import { ComponentRegistry } from '@object-ui/core';
import { withFieldCarrier } from '@object-ui/fields';
import { ColorPickerField } from './ColorPickerField';

ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), {
  namespace: 'plugin-board',
  label: 'Color Picker',
  category: 'field',
  inputs: [
    { name: 'value', type: 'string', label: 'Value' },
    { name: 'placeholder', type: 'string', label: 'Placeholder' },
  ],
});

export { ColorPickerField };

Using the ComponentRegistry

Namespaced Registration

Namespaces prevent type collisions between plugins:

import { ComponentRegistry } from '@object-ui/core';

// Register with a namespace — accessible as 'plugin-board:board' AND 'board'
ComponentRegistry.register('board', BoardRenderer, {
  namespace: 'plugin-board',
});

// Explicit lookup by namespace
ComponentRegistry.get('board', 'plugin-board');

// Fallback lookup (works when the type is unambiguous)
ComponentRegistry.get('board');

Use skipFallback: true in the metadata if you do not want the component to be available without a namespace prefix.

Querying Registered Components

ComponentRegistry.has('board');                              // boolean
ComponentRegistry.getAllTypes();                              // string[]
ComponentRegistry.getNamespaceComponents('plugin-board');     // ComponentConfig[]

Plugin Configuration & Schema Types

Define your schema interface in types.ts and extend BaseSchema:

import type { BaseSchema } from '@object-ui/types';

export interface BoardSchema extends BaseSchema {
  type: 'board';
  columns: BoardColumn[];
  items: BoardItem[];
}

Declare ComponentInput entries when registering so the visual designer can offer a property panel:

ComponentRegistry.register('board', BoardRenderer, {
  inputs: [
    { name: 'columns', type: 'array', label: 'Columns', required: true },
    { name: 'items', type: 'array', label: 'Items', required: true },
    {
      name: 'layout',
      type: 'enum',
      label: 'Layout',
      enum: ['horizontal', 'vertical'],
      defaultValue: 'horizontal',
    },
  ],
});

Testing Plugins

ObjectUI uses Vitest + React Testing Library. Place tests next to the implementation.

// src/BoardImpl.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import BoardImpl from './BoardImpl';

const schema = {
  type: 'board' as const,
  columns: [
    { id: 'todo', title: 'To Do' },
    { id: 'done', title: 'Done' },
  ],
  items: [
    { id: '1', columnId: 'todo', title: 'Write tests' },
    { id: '2', columnId: 'done', title: 'Ship plugin' },
  ],
};

describe('BoardImpl', () => {
  it('renders all columns', () => {
    render(<BoardImpl schema={schema} />);
    expect(screen.getByText('To Do')).toBeInTheDocument();
    expect(screen.getByText('Done')).toBeInTheDocument();
  });

  it('renders items in correct columns', () => {
    render(<BoardImpl schema={schema} />);
    expect(screen.getByText('Write tests')).toBeInTheDocument();
    expect(screen.getByText('Ship plugin')).toBeInTheDocument();
  });

  it('handles empty items gracefully', () => {
    render(<BoardImpl schema={{ ...schema, items: [] }} />);
    expect(screen.getByText('To Do')).toBeInTheDocument();
  });
});

Run tests:

pnpm vitest run packages/plugin-board

Publishing Guidelines

Package Checklist

Before publishing, verify:

  • package.json has correct name, version, exports, and peerDependencies.
  • react and react-dom are peer dependencies, not direct dependencies.
  • @object-ui/core and @object-ui/components are in devDependencies (or peerDependencies).
  • vite.config.ts marks React and ObjectUI packages as external.
  • Types are exported via "types" field in package.json.
  • All tests pass (pnpm vitest run).
  • The entry point is lightweight — heavy code lives in *Impl.tsx files.

Build & Verify

pnpm build --filter @object-ui/plugin-board
ls -lh packages/plugin-board/dist/

The entry chunk should be under 1 KB; the lazy chunk carries the bulk.

Publish

cd packages/plugin-board
npm publish --access public

Consumers Install & Use

pnpm add @object-ui/plugin-board
// app/main.tsx — import once, auto-registers
import '@object-ui/plugin-board';
{
  "type": "board",
  "columns": [
    { "id": "todo", "title": "To Do" },
    { "id": "done", "title": "Done" }
  ],
  "items": [
    { "id": "1", "columnId": "todo", "title": "Write docs" }
  ]
}

Related Documentation