#Extending a Module
A module you installed is not a sealed box. An app can replace one of its pages, add pages around it, swap one of its services and change its options, all from its own code, without forking the package or asking it for a hook. This is what lets one product ship as a package and several customer apps each carry their own differences.
Three mechanisms do it, plus the module's own options:
| You want to | Use | Where |
|---|---|---|
| Replace a page's component, or any primitive option | primitive.override({ ... }) |
before start() |
| Add a page of yours under a page of the package | parent: this.router.page on your page |
your router |
| Put the package's pages under a layout of yours | children: () => [...] on your layout |
your router |
| Replace a service's behaviour | alepha.with({ provide, use }) |
before the module |
| Change a setting the package exposes | alepha.set(atom, value) on its atom |
before start() |
| Replace one component inside a page | a slots service, swapped like a service | before the module |
The rest of this page is one example: a package, then an app that changes it in each of those ways.
#The package
@acme/crm exports a router, a service, an options atom, and the module that
groups them:
1import { $atom, $inject, $module, Alepha, z } from "alepha"; 2import { $page, NestedView } from "alepha/react/router"; 3import type { FC } from "react"; 4 5export const crmOptions = $atom({ 6 name: "crm.options", 7 schema: z.object({ pageSize: z.integer() }), 8 default: { pageSize: 25 }, 9});10 11export class PricingService {12 quote(amount: number): string {13 return `${amount} EUR`;14 }15}16 17// The components the package lets an app replace, one field each.18export class CrmSlots {19 ClientBadge: FC<{ name: string }> = (props) => <b>{props.name}</b>;20}21 22export class CrmRouter {23 protected readonly alepha = $inject(Alepha);24 protected readonly pricing = $inject(PricingService);25 protected readonly slots = $inject(CrmSlots);26 27 clients = $page({28 path: "/clients",29 component: () =>30 `Clients, ${this.alepha.store.get(crmOptions).pageSize} per page`,31 });32 33 client = $page({34 path: "/clients/:id",35 component: () => {36 const { ClientBadge } = this.slots;37 return (38 <>39 <ClientBadge name="Ada" /> owes {this.pricing.quote(100)}40 <NestedView />41 </>42 );43 },44 });45}46 47export const CrmModule = $module({48 name: "crm",49 services: [CrmRouter, PricingService, CrmSlots],50 atoms: [crmOptions],51});
Nothing here was written for extension except CrmSlots and the
<NestedView /> in client. Everything else is an ordinary module.
#Replace a page
Every primitive has override(): it shallow-merges new options into the ones
the package declared. On a $page that means its component, its lazy
import, its loader, its head: anything $page takes.
1const alepha = Alepha.create().with(CrmModule);2 3alepha.inject(CrmRouter).clients.override({4 component: () => <AcmeClients />,5});
/clients now renders AcmeClients. The path, the name and every option you
did not pass stay the package's, so links to the page keep working.
The same thing as a class, when you would rather keep the changes to one router in one place:
1class AcmeCrmRouter extends CrmRouter { 2 constructor() { 3 super(); 4 this.clients.override({ component: () => <AcmeClients /> }); 5 } 6} 7 8const alepha = Alepha.create() 9 .with({ provide: CrmRouter, use: AcmeCrmRouter })10 .with(CrmModule);
override() works on any primitive, not only pages: an $action's options, a
$job's schedule, a $hook's event.
#Add a page under one of the package's
Your page names the package's page as its parent. It takes the parent's
path as a prefix and renders where the parent puts its <NestedView />:
1class AcmeRouter {2 protected readonly crm = $inject(CrmRouter);3 4 contracts = $page({5 parent: this.crm.client,6 path: "/contracts",7 component: () => <ClientContracts />,8 });9}
/clients/1/contracts renders the package's client page with
ClientContracts inside it.
#Put the package's pages under your layout
The other direction: your layout adopts pages it does not own, with
children. See Nested Routing.
1class AcmeRouter { 2 protected readonly crm = $inject(CrmRouter); 3 4 shell = $page({ 5 path: "/acme", 6 children: () => [this.crm.clients, this.crm.client], 7 component: () => ( 8 <AcmeShell> 9 <NestedView />10 </AcmeShell>11 ),12 });13}
The package's pages now live at /acme/clients and /acme/clients/:id,
inside AcmeShell.
#Swap a service
Substitute the class, and everything in the package that injects
PricingService gets yours instead. See
Swapping Implementations.
1class AcmePricingService extends PricingService {2 override quote(amount: number): string {3 return `${amount * 1.2} EUR incl. VAT`;4 }5}6 7const alepha = Alepha.create()8 .with({ provide: PricingService, use: AcmePricingService })9 .with(CrmModule);
Extend the original and override what differs, so the methods you leave alone, and the ones a later version of the package adds, keep working.
#Change an option
A package that exposes its settings as an atom lets you set them without
touching a class, any time before start():
1const alepha = Alepha.create().with(CrmModule);2 3alepha.set(crmOptions, { pageSize: 50 });
A setting read from the environment is changed there instead: see Configurations.
#Replace one component inside a page
override() replaces a page's whole component. It cannot reach inside it and
change one button: a component is a function, with nothing to merge into.
A package that wants a part of a page to be replaceable says so, by reading
that part from a service. That is CrmSlots above: the page renders
this.slots.ClientBadge, so the app swaps the service:
1class AcmeSlots extends CrmSlots {2 override ClientBadge: FC<{ name: string }> = (props) => (3 <mark>{props.name}</mark>4 );5}6 7const alepha = Alepha.create()8 .with({ provide: CrmSlots, use: AcmeSlots })9 .with(CrmModule);
If the package offers no such service, the options are to replace the whole
page with override(), or to ask the package for a slot.
#Limits
override()is for beforestart(). Options are consumed while the app starts (routes are registered, jobs scheduled), so after that it throwsCannot override primitive '...' after Alepha has started. Call it right after registering the module, or in a subclass constructor.override()is a shallow merge. A nested option, such as a$page'sschema, is replaced whole, not merged field by field.- A substitution comes before the module.
alepha.with({ provide, use })must be recorded before anything builds the original; registering the module builds its services, so a substitution placed afterwith(CrmModule)throwsTooLateSubstitutionError. So does a second substitution of the same service, unless it saysoptional: true, which keeps the first one: that is how a package declares a default the app may replace. - A subclass sees
protectedmembers, notprivateones. A package meant to be extended declares its membersprotected. - Inner components need a slot. See above: only what the package reads from a service, an atom or an option can be changed from outside.
- You are overriding a version. An option you replace no longer follows the package: when it changes that page's loader or props in a later release, your component is the one to update.