dodil/erp-ui

src/contract.ts

/**
 * The module UI contract — what every brick's UI exports, whichever way the product is assembled
 * (one bundle, federated at runtime, or each brick serving its own). One contract is what lets the
 * assembly mode be a setting rather than three systems.
 *
 *   screens      routes the brick owns, with the permission needed to see them
 *   offers       named extension points on its screens, where other bricks may add panels
 *   extensions   panels it adds to OTHER bricks' extension points
 *
 * A brick never imports another brick's UI. It links by URL and plugs into extension points — the UI's
 * version of a seam.
 */
import type { ComponentType } from "react";

export interface Screen {
  /** Path inside the brick, e.g. "/partners/:bpId". The shell mounts it under "/<brick>". */
  path: string;
  title: string;
  /** In the navigation when set — the section it sits in. Detail screens usually have none. */
  nav?: { section: string; label?: string; order?: number };
  /** Hidden from callers without it. Omitted = any signed-in user. */
  permission?: string;
  component: ComponentType<Record<string, never>>;
}

export interface ExtensionPoint {
  /** "<brick>.<screen>.<place>", e.g. "bp.partner.panels" */
  id: string;
  /** What the host passes to every panel at this point, e.g. ["bpId"]. */
  context: string[];
  description: string;
}

export interface Extension {
  /** The extension point it fills. */
  point: string;
  title: string;
  permission?: string;
  order?: number;
  component: ComponentType<{ context: Record<string, string> }>;
}

export interface ModuleUI {
  /** The brick's name — the same as its backend's, e.g. "bp". */
  name: string;
  version: string;
  screens: Screen[];
  offers?: ExtensionPoint[];
  extensions?: Extension[];
}

/** Everything wrong with a set of module UIs, before anything is shown. Empty = they fit together. */
export function checkModules(modules: ModuleUI[]): string[] {
  const problems: string[] = [];
  const names = modules.map((m) => m.name);
  names.filter((n, i) => names.indexOf(n) !== i).forEach((n) => problems.push(`module ${n} is listed twice`));
  const points = new Map<string, string>();
  for (const m of modules) {
    const paths = m.screens.map((s) => s.path);
    paths.filter((p, i) => paths.indexOf(p) !== i).forEach((p) => problems.push(`${m.name}: two screens at ${p}`));
    for (const s of m.screens) {
      if (!s.path.startsWith("/")) problems.push(`${m.name}: screen path ${s.path} must start with /`);
    }
    for (const p of m.offers ?? []) {
      if (!p.id.startsWith(`${m.name}.`)) problems.push(`${m.name}: extension point ${p.id} must start with "${m.name}."`);
      if (points.has(p.id)) problems.push(`extension point ${p.id} is offered by both ${points.get(p.id)} and ${m.name}`);
      points.set(p.id, m.name);
    }
  }
  for (const m of modules) {
    for (const e of m.extensions ?? []) {
      if (!points.has(e.point)) problems.push(`${m.name}: "${e.title}" plugs into ${e.point}, which no module offers`);
    }
  }
  return problems;
}

/** The panels for one extension point, in order, that this caller may see. */
export function extensionsFor(modules: ModuleUI[], point: string, can: (permission?: string) => boolean): Extension[] {
  return modules
    .flatMap((m) => m.extensions ?? [])
    .filter((e) => e.point === point && can(e.permission))
    .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
}