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 }],Search
The search box in the top bar (Ctrl+K, or ⌘K on a Mac) asks every search provider for hits shortly
after the editor stops typing. It shows up to 14 hits, grouped by their group. With nothing typed,
it lists the menu's entries.
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.