Skip to content

Portal Extensions

A portal extension is a Vue + TypeScript module bundled in a Hantera app. When the app is installed, Hantera loads the extension and calls the default export in portal/index.ts with a Portal instance.

From there, the extension can:

  • Register UI components into existing portal slots
  • Register custom views and navigation entries
  • Register services that the portal can call (service contracts)
ts


export default function (portal: Portal) {
  portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent);
}

Registering a component

The portal exposes predefined “slots” which act as extension points in existing UI surfaces. Slots are grouped under a few namespaces in apps, such as:

  • apps.orderViewSlots
  • apps.standardServicesSlots
  • apps.workspaceSlots
  • apps.legacySlots
  • apps.defineService

When you register a component to a slot, the portal renders your component in that location.

ts
portal.registerComponent(apps.orderViewSlots.delivery.footer, MyComponent);

This tells the portal:

When rendering the delivery footer in the order view, mount MyComponent here.

Accessing slot context inside the component

Each slot gives your component a typed context within the UI surface.Inside the component, the context has to request for the same slot:

ts

const ctx = apps.componentContext(apps.orderViewSlots.delivery.footer);
const delivery = ctx.delivery;

Here’s what is happening:

  • apps.orderViewSlots.delivery.footer identifies the UI surface.
  • componentContext(...) returns the typed runtime context for that surface.
  • Because this slot belongs to the order delivery view, the context includes delivery-related data.

for example:

vue
<template>
  Current delivery number: {{ delivery?.deliveryNumber }}
</template>

The component does not fetch this data manually. The portal injects the delivery number through the slot context.

Registering views

A view is a registered UI route in the portal. Views are implemented as Vue components and registered from portal/index.ts.

ts

const testView = portal.registerView("test", Test);

registerView() returns metadata about the view, including its fullPath, which can be used to open the view from navigation.

Registering NavHub entries

NavHub entries add navigation items in the portal. Typically, you register a section and an item that opens your view.

ts
portal.registerNavHubSection({
  id: "test",
  items: [
    {
      label: "My test view",
      action(viewContext) {
        viewContext.openView(testView.fullPath, {});
      },
    },
  ],
});

View context and state

Each view has a context object that includes route information and a state object. The state can be used to store view-specific values while the view is active.

ts

const context = useContext();

if (!context.currentView.value!.state.opened) {
  context.currentView.value!.state.opened = Date.now();
}

Registering a service

A service is how an app exposes functionality to the portal through a contract. Instead of importing and syncing external data into Hantera, the portal can call your service at runtime and use the returned results inside standard UI flows.

Services are registered from portal/index.ts:

ts
portal.registerService(apps.standardServices.productsService, (serviceContext) => ({
  lookup(productNumber: string) {
    // ...
  },
  order(order, phrase: string) {
    // ...
  },
  search(phrase: string) {
    // ...
  },
}));

Where service code runs

Services are registered in portal/index.ts as part of the portal extension. They run inside the portal runtime, but they are not executed inside a specific Vue component’s setup context.

This means:

A service implementation cannot use component-only composition hooks such as useContext() or useAppContext().

Instead, the portal provides a serviceContext object when the service is registered.

Use serviceContext for portal-facing utilities such as:

  • serviceContext.processes: Used to run tasks with visible progress overlays, report errors, and surface long-running work in the portal UI.
  • Any other portal-provided helpers exposed through the service context.

Example:

ts
portal.registerService(apps.standardServices.productsService, (serviceContext) => ({
  async lookup(productNumber: string) {
    serviceContext.processes.start({
      code: "LOOKUP_STARTED",
      message: "Looking up product..."
    })

    // fetch logic here

    return []
  }
}))

Consuming services inside Vue components

Although services are defined in index.ts, they can be consumed from Vue components.

Example inside a .vue file:

ts
const appContext = useAppContext()

const productServices = appContext.getServices(
  apps.standardServices.productsService
)

const product =
  productServices.length > 0
    ? await productServices[0].lookup("palissade-table-iron-red-small")
    : null

Here:

  • The Vue component uses useAppContext() to retrieve registered services.
  • The component calls lookup().
  • The service runs in the portal runtime and returns data back to the component.

Multiple providers

Multiple apps can register the same service contract. The portal can merge results from multiple providers into a unified suggestion list.

The products service contract

The standard products service contract is designed to provide product suggestions in different contexts. It typically exposes three methods:

Lookup

Use lookup when the portal already has a specific product number and wants details to display or validate.

Input: a known productNumber

Output: a single product suggestion (or null if not found)

Example:

ts
lookup(productNumber: string) {
  return Promise.resolve({
    productNumber,
    description: "Test lookup",
  });
}

Order

Use order when the user is editing an order and needs product suggestions while searching.

This is the order editor path, so it is where you usually want richer behavior:

  • tailor results based on order context
  • tailor pricing, tax behavior, availability, or localization
  • return SKUs in the structure the portal expects for order lines

Input: the current order context + a search phrase

Output: a list of OrderProduct suggestions

Example:

ts
async order(order, phrase) {
  const response = await fetch(`<external api link>`), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query: `query GetProducts {
        browse {
          product(term: "${phrase}") {
            hits {
              variants {
                sku
                firstImage { url }
                name
                defaultPrice
              }
            }
          }
        }
      }`,
    }),
  });

  if (!response.ok) {
    serviceContext.processes.error({
      code: "ERROR",
      message: "Product lookup failed",
    });
    return [];
  }

  const hits = (await response.json()).data.browse.product.hits as {
    variants: { sku: string; firstImage: { url: string }; name: string; defaultPrice: number }[];
  }[];

  return hits
    .flatMap((h) => h.variants)
    .map((v) => {
      const skus: Record<string, Decimal> = {};
      skus[v.sku] = new Decimal(1);

      return <apps.standardServices.OrderProduct>{
        description: v.name,
        productNumber: v.sku,
        imageUrl: v.firstImage.url,
        skus,
        unitPrice: new Decimal(v.defaultPrice),
        taxFactor: new Decimal(0),
      };
    });
}

Use search for product suggestions outside an order context. For:

  • claims/returns flows
  • a product picker that is not tied to an order editor

Input: a search phrase

Output: a broader list of suggestions (contract-specific type)

Example:

ts
search(phrase: string) {
  return Promise.resolve([]);
}

Placing external endpoints and secrets

If your service needs an external endpoint or key (URL, token, etc.), don’t hardcode it. Prefer app settings in h_app.yaml so it can be configured per tenant, then read it at runtime (via registry-backed settings). This keeps the portal extension portable and avoids baking environment values into code. Refer to App Settings

© 2024 Hantera AB. All rights reserved.