Rozszerzanie widoków przez hooki
Moduł może w dowolnym miejscu swojej strony pozostawić zaczep widokowy – punkt, w którym inne moduły dokładają własne sekcje UI. Zaczep to blok OVE type: dynamic ze schemaFrom, który woła metodę modułu-właściciela. Metoda emituje hook views.<nazwaStrony>, zbiera fragmenty widoku zwrócone przez moduły rozszerzające i skleja je w jeden root widget – ten widget wraca do przeglądarki i zostaje wyrenderowany w miejscu bloku.
Dwie rzeczy są tu istotne:
- Zależność jest jednostronna. Właściciel strony nie zna modułów rozszerzających – nie wywołuje ich metod, nie ma ich w zależnościach i działa poprawnie, gdy żaden z nich nie jest zainstalowany. Wystarczy zainstalować nowy moduł rejestrujący hook, żeby na stronie pojawiła się jego sekcja.
- Fragment to dane, nie kod. Moduł zwraca opis widoku OVE, a nie HTML. Zanim schemat zostanie wyrenderowany, przechodzi walidację
schemaGuard: dozwolone są wyłącznie znane typy widoków i znane akcje, typy wstawiające surowy kod strony (html,cssbox) wymagają jawnej zgodyallow, a rejestrowanie komponentów (components) jest zabronione.
Dalej opisane są dwa kontrakty core.orders, które trzymają się tej samej zasady jednostronnej zależności: flagi na liście zamówień (hook orders.flags) i powrót z karty zamówienia (zmienna ordersReturnTarget).
Jak to działa
Strona OVE właściciela Metoda właściciela Moduł rozszerzający
──────────────────────── ──────────────────────── ────────────────────────
type: dynamic
method: getOrderDetailsBlock ───► requirePermission / pobranie
schemaFrom: view zamówienia
Core.execHook(
'views.orderDetails',
{ orderId, order }) ─────► registerHook('views.orderDetails', handler)
◄───── { key, title, order, view }
walidacja + sortowanie
po `order`, sklejenie
w root `flex`
◄───────────────────────────────── { view, fragments }
render (schemaGuard)
Konwencja nazewnictwa hooków widokowych
Hooki widokowe przyjmują prefiks views. i opisują stronę, na której się pojawiają:
views.<nazwaStrony>
Przykłady:
views.orderDetails – strona szczegółów zamówienia
views.customerProfile – profil klienta
views.productEdit – edycja produktu
Właścicielem nazwy i kontraktu jest moduł, który hook emituje – to on decyduje, co trafia do handlerów i jak wygląda fragment.
Dokumentuj swoje zaczepy
Jeśli Twój moduł wystawia zaczepy widokowe, opisz je w dokumentacji razem ze strukturą obiektu przekazywanego do handlera. Umożliwi to innym deweloperom tworzenie rozszerzeń bez zaglądania do kodu.
Kontrakt fragmentu
Handler dostaje dane zaczepu (dla views.orderDetails jest to { orderId, order }) i zwraca jeden fragment, tablicę fragmentów albo null/undefined, gdy nie ma nic do pokazania:
{
key: 'core.shipping', // stabilny identyfikator (zwykle kod modułu)
title: 'Przesyłki', // opcjonalny; diagnostyka i logi – nagłówek ma sam fragment (karta)
order: 20, // kolejność sklejania rosnąco; brak → 100
view: { type: 'card' } // czysty root widget OVE
}
Zasady po stronie emitenta:
| Sytuacja | Zachowanie |
|---|---|
Brak key (string) lub brak view.type (string) | Fragment odrzucony, ostrzeżenie w logu |
Powtórzony key | Wygrywa pierwszy fragment, kolejny odrzucony (log) |
Brak order | Domyślnie 100 |
Równe order | Zostaje kolejność odpowiedzi hooka |
null / undefined | Poprawny brak fragmentu – bez ostrzeżenia |
| Brak jakiegokolwiek fragmentu | Metoda zwraca view: null, a DynamicView pokazuje emptyMessage |
Wyjątek w handlerze to pusta odpowiedź
Protokół hooka nie ma kanału błędów – wyjątek rzucony w handlerze daje pustą odpowiedź, więc fragment po prostu zniknie ze strony bez śladu w logach modułu. Dlatego cała treść handlera powinna być w try/catch, a błąd trzeba zalogować samemu i zwrócić null.
Przykład – strona szczegółów zamówienia
Moduł core/orders – właściciel widoku
Na stronie order-details-v2.yaml miejsce na fragmenty innych modułów to jeden blok dynamiczny:
- type: dynamic
id: orderDetailsBlock
module: core/orders
method: getOrderDetailsBlock
params:
orderId: '{{ $page.urlParams.orderId }}'
schemaFrom: view
failSoft: false
loadingMessage: Ładowanie modułów zamówienia...
emptyMessage: Brak modułów rozszerzających kartę zamówienia
fetchErrorMessage: Nie udało się załadować bloku modułów zamówienia
schemaErrorMessage: Moduły zwróciły niepoprawny widok
schemaFrom: view mówi silnikowi, że pod kluczem view w odpowiedzi metody znajduje się schemat widoku (zamiast lokalnego template).
Metoda getOrderDetailsBlock sprawdza uprawnienie, pobiera zamówienie, emituje hook i skleja fragmenty:
const { Core, DB, Log } = require('@orbito/api-lib');
const Order = require('../src/models/Order');
const { PERMISSIONS, requirePermission } = require('../src/register-permissions');
const {
ORDER_DETAILS_HOOK, // 'views.orderDetails'
normalizeFragments, // walidacja, deduplikacja, sortowanie
buildOrderDetailsView, // sklejenie w root widget
} = require('../src/lib/order-details-block');
module.exports = {
description: 'Buduje blok karty zamówienia z fragmentów dostarczonych przez inne moduły',
params: {
orderId: { type: 'string', required: true },
},
handler: async function(params, ctx) {
requirePermission(PERMISSIONS.ORDERS_VIEW, ctx);
const order = await Order.findOne({ _id: new DB.ObjectId(params.orderId) });
if (!order) {
throw new Error('Order not found');
}
// Handlery dostają surowy dokument zamówienia – fragmenty i tak wołają
// własne metody modułu po `orderId`.
const responses = await Core.execHook(ORDER_DETAILS_HOOK, {
orderId: String(order._id),
order,
});
const fragments = normalizeFragments(responses, (message, details) => Log.warn(message, details));
return {
view: buildOrderDetailsView(fragments),
fragments: fragments.map(({ key, title, order: position }) => ({ key, title, order: position })),
};
},
};
Sklejony widok to zwykła kolumna – fragmenty renderują się jeden pod drugim:
{
type: 'flex',
id: 'orderDetailsBlock',
direction: 'column',
gap: 3,
childs: fragments.map(fragment => fragment.view),
}
Moduł core/shipping – rozszerzający widok
Moduł przesyłek rejestruje handler w index.js:
const { Core } = require('@orbito/api-lib');
const { orderDetailsViewHandler } = require('./src/hooks/order-details-view');
// Zależność jest jednostronna: `core.orders` emituje hook i skleja odpowiedzi,
// nie znając tego modułu.
Core.registerHook('views.orderDetails', orderDetailsViewHandler);
Sam handler nie buduje widoku w kodzie – ładuje schemat z pliku YAML i podstawia w nim placeholder @orderId:
const path = require('path');
const { Log } = require('@orbito/api-lib');
const { loadViewSchema, bindPlaceholders } = require('../lib/view-schema');
const SLOT_VIEW_FILE = path.join(__dirname, '..', '..', 'pages', 'slots', 'order-details.yaml');
async function orderDetailsViewHandler(payload) {
try {
const orderId = payload?.orderId;
if (!orderId) {
return null;
}
return {
key: 'core.shipping',
title: 'Przesyłki',
order: 20,
view: bindPlaceholders(loadViewSchema(SLOT_VIEW_FILE), { orderId }),
};
} catch (err) {
Log.error('Shipping: nie udało się zbudować fragmentu widoku zamówienia', {
orderId: payload?.orderId,
error: err?.message,
});
return null;
}
}
module.exports = { orderDetailsViewHandler };
Fragment (pages/slots/order-details.yaml) to czysty root widget OVE – karta z listą przesyłek, wołająca wyłącznie metody własnego modułu:
type: card
id: shpCard
title: Przesyłki
content:
- type: list
id: shpList
module: core/shipping
method: shipments.getByOrderId
params:
orderId: '@orderId'
emptyMessage: Brak przesyłek dla tego zamówienia
loadingMessage: Ładowanie przesyłek...
template:
type: text
id: shpListRowTitle.$
content: '{{ shpList.$.carrier }} · {{ shpList.$.trackNo }}'
'@orderId' jest podstawiane przez handler przy każdym wywołaniu hooka. Fragment nie ma dostępu do $page.urlParams właściciela, więc wszystkie dane kontekstowe muszą przyjść tą drogą.
Dobre praktyki
- Prefiksuj identyfikatory. Fragmenty kilku modułów renderują się na jednej stronie i współdzielą przestrzeń zmiennych. Nadawaj
idwidoków i nazwom zmiennych strony prefiks kodu modułu (shpList,shpManualModal,invDocsList), żeby nie kolidowały z innymi fragmentami ani ze stroną właściciela. - Wołaj tylko własne metody. W
module:fragmentu wpisuj wyłącznie własny moduł – inaczej wracasz do zależności między modułami, którą zaczep miał usunąć. - Uprawnienia sprawdzaj dwustronnie. Właściciel sprawdza dostęp do całego bloku (np.
ORDERS_VIEWw metodzie emitującej hook), a moduł rozszerzający – w każdej metodzie, którą fragment wywołuje z przeglądarki. - Nie licz na kolejność rejestracji modułów. Pozycję fragmentu ustala pole
order, nie moment załadowania modułu.
Różnice względem poprzedniej wersji
- Nie ma już handlerów stron (
Pages.registerPagez callbackiem budującym widok). Strony to statyczne pliki OVE, a treść dynamiczna wchodzi przez bloktype: dynamicwołający metodę modułu. - Hooki widokowe nazywają się
views.<nazwaStrony>zamiastview:<nazwaStrony>. - Handler zwraca fragment
{ key, title, order, view }, a nie{ type: 'ove', template };viewto czysty root widget OVE. - Filtrowanie pustych odpowiedzi, deduplikacja
keyi sortowanie należą do emitenta – moduł rozszerzający zwraca po prostunull, gdy nie ma nic do pokazania.
Flagi na liście zamówień – hook orders.flags
Ten sam wzorzec obsługuje kolumnę FLAGI na liście zamówień (core/orders, strona orders). Różnica jest jedna: moduł rozszerzający nie zwraca fragmentu OVE, tylko opis flagi – dane, z których emitent sam buduje ikonę w wierszu i treść dymka ze szczegółami.
Hook jest emitowany raz na stronę wyników metody search (≤ 100 zamówień) oraz raz dla pojedynczego zamówienia, gdy operator otwiera dymek flagi (metoda flags.details). Handler robi więc jedno zapytanie do bazy na całą stronę listy, a nie jedno na zamówienie.
Argument hooka
{
orderIds: ['<id>', ...],
// lekka projekcja zamówień – tyle, ile trzeba do oceny własnej flagi
orders: [{
_id: '<id>',
invoice: { isNeeded: true },
isPaid: true,
isPacked: false,
delivery: { isCod: false },
}],
}
Nie wołaj emitenta z wnętrza handlera
Projekcja orders istnieje po to, żeby handler nie musiał wołać z powrotem core/orders. Wywołanie zwrotne w trakcie obsługi hooka może zakleszczyć kontenery obu modułów.
Kontrakt odpowiedzi
Handler zwraca jeden opis flagi, tablicę opisów albo null/undefined, gdy nie ma nic do pokazania:
{
key: 'shipment', // stabilny, unikalny identyfikator (wymagany)
label: 'Przesyłka', // etykieta dla operatora (wymagana)
icon: 'truck-delivery', // ikona Tabler bez prefiksu `ti-` (wymagana)
order: 30, // kolejność w wierszu rosnąco; brak → 100
inactive: { // opcjonalny opis stanu „nic się nie wydarzyło”
title: 'Brak przesyłki',
summary: 'Nie utworzono przesyłki dla zamówienia',
},
states: { // stan per zamówienie – tylko dla tych, które go mają
'<orderId>': {
active: true,
tone: 'success', // success | info | warning | muted
title: 'Przesyłka utworzona',
summary: 'Kurier: DPD\nNumer przesyłki: 0001234567',
},
},
}
Zasady po stronie emitenta:
| Sytuacja | Zachowanie |
|---|---|
null / undefined | Poprawny brak flagi – bez ostrzeżenia |
Brak key, label albo icon (niepuste stringi) | Opis odrzucony, Log.warn('Orders: odrzucono flagę zamówienia', { key, reason }) |
Powtórzony key | Wygrywa pierwszy opis, kolejny odrzucony (log) |
key zarezerwowany (payment, cod, packed, photo) | Opis odrzucony – to flagi natywne core.orders |
Brak order | Domyślnie 100; flagi natywne mają 10, 20, 40, 50, więc zewnętrzne wchodzą między nie własnym order |
Równe order | Najpierw flagi natywne, dalej kolejność odpowiedzi hooka |
Brak wpisu w states dla zamówienia | Flaga nieaktywna: tone: muted, title/summary z inactive (albo sama label) |
tone spoza listy | muted |
| Awaria hooka (wyjątek, timeout) | Lista zamówień zostaje z samymi flagami natywnymi, powód trafia do Log.warn |
Wiersz metody search dostaje gotową tablicę posortowaną po order: flags: [{ key, label, icon, active, tone, title, summary, imageDataUrl }].
Szczegóły są eager
title i summary przychodzą w tej samej odpowiedzi hooka, co sama flaga – nie ma drugiego kontraktu „szczegóły flagi” i żadnych wywołań zwrotnych przy otwieraniu dymka. Handler i tak ma jedno zapytanie $in po orderIds, a opis to ok. 100 B na zamówienie.
Cały handler w try/catch
Jak przy hookach widokowych: wyjątek w handlerze daje pustą odpowiedź bez śladu w logach modułu. Błąd loguj sam i zwracaj null – lista zamówień pokaże się wtedy bez Twojej flagi.
Przykład – flaga „Przesyłka” w core/shipping
const { Core, Log } = require('@orbito/api-lib');
const Shipment = require('./src/models/Shipment');
// Emitent (`core.orders`) woła hook raz na stronę wyników, a tam `perPage` jest ≤ 100.
const MAX_ORDER_IDS = 100;
Core.registerHook('orders.flags', async payload => {
try {
const orderIds = (payload?.orderIds || [])
.filter(orderId => typeof orderId === 'string' && orderId.trim())
.slice(0, MAX_ORDER_IDS);
if (!orderIds.length) {
return null;
}
// Jedno zapytanie na całą stronę listy.
const shipments = await Shipment.find({ orderId: { $in: orderIds } }, 1000, 0, { createdAt: -1 });
const states = {};
for (const shipment of shipments) {
const orderId = String(shipment.orderId || '');
if (!orderId || states[orderId]) {
continue;
}
states[orderId] = {
active: true,
tone: shipment.status === 'error' ? 'warning' : 'success',
title: 'Przesyłka utworzona',
summary: `Kurier: ${shipment.carrier}\nNumer przesyłki: ${shipment.trackNo}`,
};
}
return {
key: 'shipment',
label: 'Przesyłka',
icon: 'truck-delivery',
order: 30,
inactive: {
title: 'Brak przesyłki',
summary: 'Nie utworzono przesyłki dla zamówienia',
},
states,
};
} catch (err) {
Log.error('Shipping: nie udało się zbudować flagi przesyłki', { error: err?.message });
return null;
}
});
Zamówienia bez wpisu w states nie wymagają żadnej obsługi – emitent pokazuje im szarą flagę zbudowaną z inactive.
Powrót z karty zamówienia – zmienna ordersReturnTarget
Moduł, który linkuje ze swojej listy do karty zamówienia (navigate do core/orders, strona details/<orderId>), zostawia przed nawigacją cel powrotu w zmiennej globalnej ordersReturnTarget. Karta zamówienia renderuje z niej przycisk „Wróć do …” i dzięki temu nie zna żadnego z modułów, które do niej prowadzą.
{
module: 'core/shipping', // moduł, do którego wraca przycisk
page: 'shipments', // strona tego modułu
label: 'przesyłek', // dopełniacz do tytułu „Wróć do przesyłek”
}
Kto co robi:
| Rola | Kiedy | Co ustawia |
|---|---|---|
| Moduł linkujący | Tuż przed navigate do karty zamówienia | setGlobalVar ordersReturnTarget z obiektem { module, page, label } |
| Moduł linkujący | W onLoad własnej listy | setGlobalVar ordersReturnTarget → null (żeby cel nie został po poprzedniej wizycie) |
core.orders | Po kliknięciu przycisku „Wróć do …” | Kasuje zmienną (null) i nawiguje pod module/page z celu |
core.orders | Gdy zmienna jest pusta | Pokazuje domyślny przycisk „Wróć do zamówień” |
Ustawienie celu w akcji OVE to dwa kroki w jednym onClick:
onClick:
- action: setGlobalVar
key: ordersReturnTarget
value:
module: core/shipping
page: shipments
label: przesyłek
- action: navigate
module: core/orders
page: details/{{ shpShipmentsTable.$.orderId }}
A zerowanie przy wejściu na własną listę – jeden krok w onLoad:
onLoad:
- action: setGlobalVar
key: ordersReturnTarget
value: null
Zmienna globalna żyje dłużej niż strona
ordersReturnTarget jest wspólna dla całego panelu, więc bez zerowania przycisk powrotu prowadziłby do listy odwiedzonej kilka nawigacji wcześniej. Zeruj ją zawsze, gdy operator wchodzi na Twoją listę.
Podsumowanie
| Rola | Mechanizm | Zadanie |
|---|---|---|
| Właściciel strony | type: dynamic + schemaFrom | Wskazuje metodę, która zbuduje blok |
| Właściciel strony | Core.execHook('views.<strona>', data) | Zbiera fragmenty, waliduje je i skleja w jeden root widget |
| Moduł rozszerzający | Core.registerHook('views.<strona>', handler) | Zwraca fragment { key, title, order, view } albo null |
| Właściciel listy | Core.execHook('orders.flags', { orderIds, orders }) | Zbiera opisy flag, waliduje je i skleja z flagami natywnymi |
| Moduł rozszerzający | Core.registerHook('orders.flags', handler) | Zwraca opis flagi { key, label, icon, order, inactive, states } albo null |
| Moduł linkujący do zamówienia | setGlobalVar ordersReturnTarget | Zostawia cel powrotu { module, page, label } dla przycisku „Wróć do …” |
