Relatude Help

Extension points

contributes takes five kinds of contribution, each with a short example below. A plugin on the store puts a blade, a widget and a search together in one plugin.

NavContribution Is
id, title, icon the entry
opens, param the blade it opens, and the param that blade gets
order its place: Dashboard is 10, Content tree 15, Content 20; 100 when left out
section 'main', the default, or 'system', the group at the bottom with Settings
nav: [
  { id: 'stock', title: 'Stock', icon: 'package', opens: 'stock.list', order: 25 },
  { id: 'stock.out', title: 'Out of stock', icon: 'alert', opens: 'stock.list', param: 'out' },
],

Blades

The interface's screens are blades: panels side by side, the newest on the right. A blade is a React component that takes BladeProps and renders a <Blade> from @relatude/ui.

BladeDefinition Is
id the name it is opened by. Begin it with the plugin's name, stock.list: a blade registered later with the same id replaces the earlier one, the interface's own included
component the component
width its width in pixels, 480 when left out, or 'fill' for the rest of the screen. Editors can drag a blade's edge to resize it
BladeProps Is
param the one string the blade was opened with: an id, a type, a filter
close() closes the blade and the blades to its right
openChild(bladeId, param?) opens a blade to its right, closing any already there
replaceParam(param) changes its param without remounting it
setCloseGuard(guard) an async check asked before the blade closes; false keeps it open, and null removes the guard
depth its place in the stack, 0 for the first
entryKey its key in the stack

The stack of blades is the page's address, as in #/content.types!content.list:IArticle!content.edit:<id>, so a reload, a link or the browser's back button brings the same blades back. A blade therefore takes what it shows from param, and loads the rest itself:

import { type BladeProps } from '@relatude/sdk';
import { Blade, Button } from '@relatude/ui';

function StockBlade({ param, close, openChild }: BladeProps) {
  const onlyOut = param === 'out';
  return (
    <Blade title={onlyOut ? 'Out of stock' : 'Stock'} icon="package" onClose={close}>
      <Button icon="plus" onClick={() => openChild('content.edit', 'new:IProductPage')}>
        New product
      </Button>
    </Blade>
  );
}

A close guard keeps unsaved work:

useEffect(() => {
  props.setCloseGuard(dirty
    ? () => cms.dialogs.confirm({ title: 'Discard changes?', confirmLabel: 'Discard', danger: true })
    : null);
}, [dirty]);

Every <Blade> gets a pin button in its header. A pinned blade moves to the right of the stack and stays there, with its state, while the stack changes. The header also has a language menu and a site menu, and blades opened from a blade take its choices. Inside a blade, useCms() reads and writes in the blade's language. To open the interface's own blades, such as the node editor, see Opening the interface's own blades.

Dashboard widgets

A widget is a card on the dashboard: { id, title, component, span? }, with span: 2 for a card two columns wide. The component takes no props and reads what it needs with useCms():

function NodeCount() {
  const cms = useCms();
  const [count, setCount] = useState<number | null>(null);
  useEffect(() => { cms.store?.count().then(setCount); }, [cms]);
  return <p>{count === null ? 'Counting…' : `${count} nodes you can read`}</p>;
}

// in contributes:
widgets: [{ id: 'stock.count', title: 'Content', component: NodeCount }],
SearchProvider Is
id the provider's name
search(text, cms) the hits for the text, or a promise of them
SearchHit Is
id, title, subtitle?, icon? the hit
group? the heading it is listed under; Results when left out
run(cms) what choosing it does
const stockCommands: SearchProvider = {
  id: 'stock.commands',
  search(text) {
    if (!'out of stock'.includes(text.trim().toLowerCase())) return [];
    return [{
      id: 'stock.out',
      title: 'Show products out of stock',
      icon: 'package',
      group: 'Commands',
      run: cms => cms.blades.open('stock.list', 'out', { reset: true }),
    }];
  },
};

// in contributes:
search: [stockCommands],

A provider that throws is logged and left out, and the others' hits still show.

Property editors

The content editor shows each property with the editor registered for its editor type (table below). A plugin can add an editor or replace a built-in one: of the editors for a type, the one with the highest priority wins. The built-in editors have priority 0, so give yours a higher one.

PropertyEditorContribution Is
id the editor's name, stock.counter
propertyType the editor type it renders
component the editor: a component taking PropertyEditorProps
priority the highest wins; 0 when left out
aside optional: a component shown at the end of the field's label row, with the same props
PropertyEditorProps Is
property id (the CodeName), name, type, required, placeholder, help; options for a select, referenceType for a link
node the node being edited, with the values as edited so far, keyed by CodeName: node.values.Name
value the property's value
onChange(value) sets the value, which is saved with the rest of the node
openChild(bladeId, param?) opens a blade beside the editor: openChild('content.edit', id) opens a node. It can be missing, so call it as openChild?.(…)

The interface draws the field's label, required mark and help around your component, so render the input alone. Values have the shapes cms.data uses (The content as the screens see it).

An editor that counts the characters of every text property, with a limit for MetaDescription:

import { type PropertyEditorProps } from '@relatude/sdk';
import { Textarea } from '@relatude/ui';

function CountedTextEditor({ property, value, onChange }: PropertyEditorProps) {
  const text = typeof value === 'string' ? value : '';
  const limit = property.id === 'MetaDescription' ? 160 : undefined;
  return (
    <div style={{ display: 'grid', gap: 4 }}>
      <Textarea rows={3} value={text} placeholder={property.placeholder} onChange={e => onChange(e.target.value)} />
      <span style={{ fontSize: 12, color: limit && text.length > limit ? 'var(--r-danger)' : 'var(--r-muted)' }}>
        {limit ? `${text.length} / ${limit}` : text.length} characters
      </span>
    </div>
  );
}

// in contributes:
propertyEditors: [
  { id: 'counter.text', propertyType: 'text', component: CountedTextEditor, priority: 10 },
],

When several people edit a node together, what your editor sets with onChange reaches the others too. Only the built-in text and HTML editors show the others' cursors.

The editor type follows the C# property. The first row that fits wins:

Editor type Property
select a string with legal values, or an enum
html an HTML string ([HtmlProperty])
text a string of another string type, such as Markdown or JSON
slug a string whose name contains slug, sku, address or urlname
text a string whose name contains intro, summary, description, body, bio, text, notes or content
string any other string, and a Guid
number int, long, double, float, decimal
boolean bool
datetime DateTime, DateTimeOffset
tags string[]
file a FileValue
password a PasswordValue ([PasswordProperty])
reference a relation's "one" end, or a Reference<T>
references a relation's "many" end, or a References<T>
embedded an Embedded<T> or EmbeddedMap<TKey, TValue>: its inner nodes

Names are matched ignoring case. A property of any other type has no editor and is left out of the form. In the default form, link and embedded fields come after the others. There is also a date editor, for a day with no time ("YYYY-MM-DD"), but no C# property maps to it.