aster.UI / Docs
Aster products
Showcase ↗

Component explorer / Application controls

Composition: a JS and YAML workspace

Declare a table, tiles, actions and a lazy editor around one shared model, with application logic in ordinary JavaScript.

Live preview

Open full size ↗

The definition is authored in YAML and compiled to JSON at build time. Registered functions execute the behaviour. Mock edits, selection and drafts last for this visit; browser reload does not restore them.

Code for this example

These excerpts come from the code running above, formatted for reading. They focus on the component behaviour; imports, fixtures and page setup are available in Full application source below.

Bind two presentations to one model

The YAML names the children and connects their collection, selection and renderer properties. It is configuration over the same runtime used by JavaScript definitions.

From scripts/site/playground/composition.ui.yaml

table:
  type: Grid
  props:
    ctx: { ref: inputs.ctx }
    label: Managed devices table
    selectable: true
    collection: { ref: model.collection }
    selection: { ref: model.selection }
    view:
      columns:
        - { field: name }
        - { field: platform, width: 130 }
        - { field: owner, width: 140 }
      sort: []
      filters: {}
      frozen: 0
tiles:
  type: TileGrid
  create: on-demand
  retain: true
  props:
    virtualized: true
    label: Managed device tiles
    selectable: true
    collection: { ref: model.collection }
    selection: { ref: model.selection }
    renderTile: { renderer: device-card }
    presentTile: { renderer: device-description }

Connect actions and their PageRules

Command names resolve to exports from the registered model. The Save rule reads published editor state; the executable save function stays in JavaScript.

From scripts/site/playground/composition.ui.yaml

commands:
  table: { use: model.commands.table }
  tiles: { use: model.commands.tiles }
  clear: { use: model.commands.clear }
  edit:
    use: model.commands.edit
    enabledWhen: devices.any
    reason: Select a device to edit.
  save:
    use: model.commands.save
    enabledWhen:
      op: and
      args:
        - editor.dirty
        - { op: not, arg: editor.busy }
    reason: Change a field before saving.
  discard: { use: model.commands.discard }

Implement queries, commands and cleanup in JavaScript

This is the running model and its save function. The lifetime releases the collection and editor. A single editor survives table/tile switching, and saving updates their shared store.

From scripts/site/playground/composition-model.js

async function saveDevice(editor) {
    const saved = await editor.save();
    if (!saved)
        throw new Error("Review the field errors before saving again.");
    return saved;
}
export function createDeviceWorkspace({ ctx, selection: selectionPolicy }, lifetime) {
    const query = signal("");
    const collection = ctx.collection(computed(() => {
        const name = query.value.trim();
        return {
            set: "Devices", orderBy: [{
                    field: "name"
                }], ...(name ? {
                filter: {
                    op: "contains", field: "name", value: name
                }
            } : {})
        };
    }), {
        pageSize: 24, keepPages: 3
    });
    lifetime.defer(() => ctx.release(collection));
    const selection = new Selection(ctx.store, collection, selectionPolicy);
    const presentation = signal("table");
    const editor = signal();
    const detailsOpen = computed(() => editor.value !== undefined);
    const dirty = computed(() => editor.value?.dirty.value ?? false);
    const busy = computed(() => editor.value?.busy.value ?? false);
    const message = signal("Select a device, open its editor, then try both presentations.");
    const closeEditor = () => { const previous = editor.value; editor.value = undefined; previous?.dispose(); };
    lifetime.defer(closeEditor);
    const ownCommand = options => lifetime.own(command(options));
    const commands = {
        table: ownCommand({
            label: "Table", run: () => { presentation.value = "table"; }
        }),
        tiles: ownCommand({
            label: "Tiles", run: () => { presentation.value = "tiles"; }
        }),
        clear: ownCommand({
            label: "Clear selection", enabled: () => selection.any.value, run: () => selection.clear()
        }),
        edit: ownCommand({
            label: "Edit selected", enabled: () => selection.any.value && !detailsOpen.value,
            reason: () => detailsOpen.value ? "Save or discard the current draft first." : "Select a device to edit.",
            run: () => { editor.value = new EditModel(ctx, "Devices", selection.key.value); message.value = "One draft is shared across both presentations. Save also works with Ctrl/⌘ S."; }
        }),
        save: ownCommand({
            label: "Save device", enabled: () => dirty.value && !busy.value,
            run: async (signal) => { const edit = editor.value; await saveDevice(edit); signal.throwIfAborted(); message.value = "Saved. The shared store updated both views."; closeEditor(); }
        }),
        discard: ownCommand({
            label: "Discard draft", enabled: () => detailsOpen.value && !busy.value,
            run: () => { closeEditor(); message.value = "Draft discarded. The stored record is unchanged."; }
        }),
    };
    return {
        values: {
            collection, selection, presentation, editor, detailsOpen, dirty, busy, query, message
        },
        publications: {
            selection: selectionPublication(selection), editor: valuesPublication({
                dirty, busy
            })
        },
        commands,
    };
}

Register a lazy child through ordinary module loading

The registry allowlists the component and supplies its dynamic import. YAML activates the named child; it cannot invent executable code or arbitrary module URLs.

From scripts/site/playground/composition.js

let detailsModule;
for (const [type, factory] of [["DeviceDetails", "createDetails"], ["CompactDetails", "createCompactDetails"]]) {
    components[type] = {
        props: {
            editor: t.optional(t.custom("EditModel", value => !!value?.save))
        },
        load: async () => { detailsModule = await import("./composition-details.js"); return detailsModule[factory]; },
        apply: (element, props) => detailsModule.applyDetails(element, props),
    };
}
Full application source Application code & shared presentation

Download composition.js

import { asterComponents, Composite, compositeElement, compositeTypes as t, defineCompositeRegistry, defineCompositeRenderer, deriveComposite, effect, html } from "../lib/aster-ui.js";
import { DataContext } from "../lib/aster-ui.js";
import { createMockService } from "../lib/aster-ui-mock.js";
import { deviceWorkspaceType } from "./composition-model.js";
import { DraftStatus } from "./composition-status.js";

const models = { "device-workspace": deviceWorkspaceType };
const components = { DraftStatus: compositeElement(DraftStatus), CommandBar: asterComponents.CommandBar, Grid: asterComponents.Grid, TileGrid: asterComponents.TileGrid };
const renderers = {};

// YAML's renderer names resolve to these ordinary functions.
function deviceCard(device) {
  return html`<div class="composition-device"><svg viewBox="0 0 48 48" aria-hidden="true"><rect x="5" y="6" width="38" height="28" rx="3"/><path d="M16 42h16M24 34v8"/></svg><strong>${device.name}</strong><span>${device.platform} · ${device.owner}</span></div>`;
}
renderers["device-card"] = defineCompositeRenderer(["renderTile"], deviceCard);
renderers["device-description"] = defineCompositeRenderer(["presentTile"], device => ({ title: device.name, description: `${device.platform}, ${device.owner}` }));

// The allowlisted module loader is code, not a URL supplied by YAML.
let detailsModule;
for (const [type, factory] of [["DeviceDetails", "createDetails"], ["CompactDetails", "createCompactDetails"]]) {
  components[type] = {
    props: { editor: t.optional(t.custom("EditModel", value => !!value?.save)) },
    load: async () => { detailsModule = await import("./composition-details.js"); return detailsModule[factory]; },
    apply: (element, props) => detailsModule.applyDetails(element, props),
  };
}

const authoring = defineCompositeRegistry({ inputs: { ctx: t.instance(DataContext) }, models, components, renderers });
const registry = authoring.registry;

const metadata = { $Version: "4.01", $EntityContainer: "Demo.Container",
  $Reference: {
    "https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json": { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] },
    "https://aster.dev/vocabularies/Aster.Data.V1.json": { $Include: [{ $Namespace: "Aster.Data.V1", $Alias: "AsterData" }] },
  }, Demo: {
  Device: { $Kind: "EntityType", $Key: ["id"], id: { $Type: "Edm.Int32" }, name: { $Nullable: false, "@Core.Description": "Name", "@AsterData.MinLength": 1 }, platform: { "@Core.Description": "Platform" }, owner: { "@Core.Description": "Owner" } },
  Container: { $Kind: "EntityContainer", Devices: { $Collection: true, $Type: "Demo.Device" } },
} };
const data = { Devices: Array.from({ length: 72 }, (_, i) => ({ id: i + 1, name: `Device ${String(i + 1).padStart(3, "0")}`, platform: ["macOS", "Linux", "Windows"][i % 3], owner: ["Design", "Engineering", "Operations"][i % 3] })) };
const out = document.querySelector("#workspace");
try {
  const mock = await createMockService({ metadata, data, wasm: new URL("../lib/odata_mock.wasm", import.meta.url) });
  const ctx = await DataContext.connect(mock.transport);
  const response = await fetch("./composition.ui.json");
  if (!response.ok) throw new Error("The workspace definition could not be loaded.");
  const { definition: sourceDefinition, sources } = await response.json();
  const definition = authoring.validate(sourceDefinition, sources);
  let instance;
  let stop;
  let timer;
  let variant = "standard";
  const variants = {
    standard: definition,
    semantic: definition,
    single: deriveComposite(definition, { name: "SingleDeviceWorkspace", modelInputs: { selection: { multiple: false, fallback: false } } }),
    inspect: deriveComposite(definition, { name: "DisplayOnlyTable", children: { table: { props: { selectable: false, label: "Devices (selection controlled by Tiles)" } } } }),
    compact: deriveComposite(definition, { name: "CompactDeviceWorkspace", children: { details: { type: "CompactDetails" } } }),
  };
  const variantChoice = document.querySelector("#variant");
  variantChoice.label = "Workspace variant";
  variantChoice.options = [ { value: "standard", label: "Standard" }, { value: "single", label: "Single selection" }, { value: "inspect", label: "Display-only table" }, { value: "compact", label: "Compact inspector" }, { value: "semantic", label: "Semantic layout" } ];
  variantChoice.value = variant;
  function mount() {
    const placement = variant === "semantic" ? authoring.placement(variants[variant], place => html`
      <section class="composition-regions" aria-label="Device workspace">
        <header>${place("views")}${place("actions")}${place("status")}</header>
        <section aria-label="Device views">${place("table")}${place("tiles")}</section>
        <aside aria-label="Device inspector">${place("details")}</aside>
      </section>`) : undefined;
    instance = new Composite(variants[variant], registry, { inputs: { ctx }, ctx, sources, ...(placement ? { placement } : {}) }).mount(out);
    stop = effect(() => {
      document.querySelector("#status").textContent = instance.model.message.value;
      document.querySelector("#rule-status").textContent = instance.commands.save.reason.value ?? "Save is available.";
    });
    document.querySelector("#query").value = "";
    globalThis.compositionDemo = { instance, registry, authoring, definition, variants, sources, ctx, mock };
  }
  mount();
  const schemaLink = document.querySelector("#download-schema");
  const schemaUrl = URL.createObjectURL(new Blob([JSON.stringify(authoring.schema(definition), null, 2)], { type: "application/schema+json" }));
  schemaLink.href = schemaUrl;
  schemaLink.download = "composition.schema.json";
  addEventListener("pagehide", event => { if (!event.persisted) URL.revokeObjectURL(schemaUrl); });
  document.querySelector("#query").addEventListener("input", event => { clearTimeout(timer); const value = event.target.value; timer = setTimeout(() => { instance.model.query.value = value; }, 160); });
  addEventListener("keydown", event => {
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void instance.commands.save.execute().catch(() => {}); }
  });
  function teardown() { clearTimeout(timer); stop(); instance.dispose(); }
  document.querySelector("#reset").addEventListener("click", async () => {
    const dirty = instance.model.dirty.value;
    if (instance.model.busy.value) return;
    if (dirty && !await confirmDiscard()) return;
    teardown(); mount();
  });
  let changing = false;
  variantChoice.addEventListener("change", async event => {
    if (event.target !== variantChoice || changing) return;
    const next = variantChoice.value;
    if (instance.model.busy.value) { variantChoice.value = variant; return; }
    changing = true;
    try {
      if (instance.model.dirty.value && !await confirmDiscard()) { variantChoice.value = variant; return; }
      teardown(); variant = next; mount();
    } finally { changing = false; }
  });
  async function confirmDiscard() {
    const dialog = document.querySelector("#confirm-reset");
    dialog.returnValue = "keep";
    dialog.showModal();
    return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "discard"), { once: true }));
  }
  addEventListener("pagehide", event => { if (!event.persisted) teardown(); });
} catch (error) { out.setAttribute("role", "alert"); out.textContent = error.message; }

Try the workflow

  1. Select a device in the table, then choose Edit selected.
  2. Change its name and switch to Tiles: selection and the draft stay shared.
  3. Save device and check the new name in both presentations.
  4. Try a workspace variant or Recreate workspace with an unsaved draft to exercise its guard.

What the example owns

The model owns its collection, selection, editor, commands and cleanup. The YAML definition connects named children, bindings, layout and PageRules. The host registry supplies the JavaScript factories, renderers and lazy module loader.