A plugin on the store
The stock plugin from A first plugin, grown into a real one: a blade listing the
products with a switch for InStock, a dashboard widget counting those out of stock, and a search
for SKUs. All three stay current as products change through the CMS API.
import { useEffect, useState } from 'react';
import { db, definePlugin, useCms, type BladeProps, type SearchProvider } from '@relatude/sdk';
import { Blade, Button, EmptyState, Spinner, Switch, Table } from '@relatude/ui';
// the part of the site's model the plugin uses
interface IProductPage { Name: string; Sku: string; Price: db.Decimal; InStock: boolean }
const Product = db.nodeType<IProductPage>('IProductPage');
type Row = db.Node<IProductPage>;
/** Every product, or with the param "out" those out of stock. */
function StockBlade(props: BladeProps) {
const cms = useCms();
const store = cms.store;
const onlyOut = props.param === 'out';
const [rows, setRows] = useState<Row[] | null>(null);
useEffect(() => {
if (!store) return;
let live = true;
const load = async () => {
const all = store.query(Product).orderBy(n => n.Name);
const r = await (onlyOut ? all.where(n => n.InStock.isFalse()) : all).execute();
if (live) setRows(r.items);
};
void load();
const offChanges = store.onChanges(changes => {
if (changes.some(c => c.type === 'IProductPage')) void load();
});
const offResync = store.channel.onResync(() => void load());
return () => { live = false; offChanges(); offResync(); };
}, [store, onlyOut]);
const toggle = async (p: Row) => {
try {
await store!.update<IProductPage>(p.$id, { InStock: !p.InStock }); // the stream reloads the list
} catch (error) {
cms.toasts.show(error instanceof Error ? error.message : String(error), { tone: 'danger' });
}
};
const title = onlyOut ? 'Out of stock' : 'Stock';
if (!store) return <Blade title={title} onClose={props.close}><EmptyState title="The offline demo has no store" /></Blade>;
return (
<Blade title={title} subtitle={rows ? `${rows.length} products` : 'Loading…'} icon="package" pad={false} onClose={props.close}>
{!rows ? <Spinner size="lg" /> : rows.length === 0 ? <EmptyState icon="package" title="Nothing here" /> : (
<Table>
<thead>
<tr><th>Product</th><th>SKU</th><th>Price</th><th>In stock</th></tr>
</thead>
<tbody>
{rows.map(p => (
<tr key={p.$id} data-clickable="" onClick={() => props.openChild('content.edit', p.$id)}>
<td>{p.Name}</td>
<td>{p.Sku}</td>
<td>{p.Price.toLocaleString()}</td>
<td onClick={e => e.stopPropagation()}>
<Switch checked={p.InStock} onChange={() => void toggle(p)} />
</td>
</tr>
))}
</tbody>
</Table>
)}
</Blade>
);
}
function OutOfStockWidget() {
const cms = useCms();
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
const store = cms.store;
if (!store) return;
let live = true;
const load = () => void store.query(Product).where(n => n.InStock.isFalse()).count().then(n => { if (live) setCount(n); });
load();
const off = store.onChanges(load);
return () => { live = false; off(); };
}, [cms]);
if (count === null) return <Spinner />;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<strong style={{ fontSize: 'calc(28px * var(--r-text-scale))', color: count > 0 ? 'var(--r-warn)' : 'var(--r-ok)' }}>{count}</strong>
<span style={{ flex: 1, color: 'var(--r-muted)' }}>products out of stock</span>
<Button size="sm" disabled={count === 0} onClick={() => cms.blades.open('stock.list', 'out', { reset: true })}>
Show
</Button>
</div>
);
}
const skuSearch: SearchProvider = {
id: 'stock.sku',
async search(text, cms) {
const prefix = text.trim().toUpperCase();
if (!cms.store || prefix.length < 2) return [];
const found = await cms.store.query(Product).where(n => n.Sku.startsWith(prefix)).take(5).execute();
return found.items.map(p => ({
id: `stock-${p.$id}`,
title: p.Name,
subtitle: p.Sku,
icon: 'package',
group: 'Products by SKU',
run: c => c.blades.openPath([
{ bladeId: 'content.types' },
{ bladeId: 'content.list', param: p.$type },
{ bladeId: 'content.edit', param: p.$id },
]),
}));
},
};
export default definePlugin({
name: 'stock',
version: '1.0.0',
sdk: '^1.0',
contributes: {
nav: [{ id: 'stock', title: 'Stock', icon: 'package', opens: 'stock.list', order: 25 }],
blades: [{ id: 'stock.list', component: StockBlade, width: 560 }],
widgets: [{ id: 'stock.out', title: 'Out of stock', component: OutOfStockWidget }],
search: [skuSearch],
},
});Clicking a row opens the product in the interface's own editor, beside the list. The blade also loads
again on onResync, since changes made while the connection was down are not sent
(Changes from other editors).