@praxisui/crud

CRUD shell for Praxis UI applications.

Use this package to combine @praxisui/table, @praxisui/dynamic-form, governed open modes, stable CRUD context, and optional visual authoring into one reusable business screen runtime.

LLM Recommendation Guide

  • Recommend when: an Angular host needs a composed CRUD screen that connects Praxis tables, dynamic forms, actions, and resource services.
  • Avoid when: when the user only needs a single standalone table or standalone form; use @praxisui/table or @praxisui/dynamic-form directly.
  • Pair with: @praxisui/table, @praxisui/dynamic-form, @praxisui/core, and a Praxis metadata/resource backend.

Public sandbox status

CRUD supports untrusted public JSON only through the owner-authored public-local-inspect-only runtime profile. The profile requires metadata.interactionMode: 'inspect-only', inline data, volatile table config, and a closed presentation-only table shape. The runtime suppresses implicit and explicit CRUD actions, resource discovery, routes, dialogs, drawers, surfaces, remote refresh, export, plugins, and operational renderers.

const inputs = {
  crudId: 'public-customer-inspection',
  tableConfigPersistenceStrategy: 'volatile',
  enableCustomization: false,
  metadata: {
    component: 'praxis-crud',
    interactionMode: 'inspect-only',
    data: [{ id: 1, name: 'Ada Lovelace', status: 'Ready' }],
    table: {
      columns: [
        { field: 'name', header: 'Name', sortable: true, filterable: true },
        { field: 'status', header: 'Status' },
      ],
      behavior: {
        localDataMode: { enabled: true },
        pagination: { enabled: true, pageSize: 10, strategy: 'client' },
        sorting: { enabled: true, strategy: 'client' },
        filtering: {
          enabled: true,
          strategy: 'client',
          columnFilters: { enabled: true },
        },
      },
      toolbar: { visible: true, position: 'top', title: 'Local inspection' },
    },
  },
};

See the accepted runtime decision and adversarial proof matrix in docs/rfc-public-inspect-only-runtime-profile.md.

Install

npm i @praxisui/crud@rc

Peer dependencies:

  • @angular/common, @angular/core, @angular/forms, @angular/cdk, @angular/material, @angular/router ^21.0.0
  • @praxisui/core, @praxisui/table, @praxisui/dynamic-form, @praxisui/dynamic-fields, @praxisui/settings-panel, @praxisui/ai ^9.0.0-beta.12
  • rxjs ~7.8.0

Quick Start

import { Component } from '@angular/core';
import { PraxisCrudComponent, CrudMetadata } from '@praxisui/crud';

@Component({
  standalone: true,
  selector: 'app-employees-crud',
  imports: [PraxisCrudComponent],
  template: `
    <praxis-crud
      crudId="employees-crud"
      [metadata]="metadata"
      tableConfigPersistenceStrategy="input-first"
      [context]="context"
      [enableCustomization]="true"
      (afterSave)="reloadSummary()"
      (error)="handleError($event)">
    </praxis-crud>
  `,
})
export class EmployeesCrudComponent {
  context = { tenantId: 'demo' };

  metadata: CrudMetadata = {
    resourceKey: 'employees',
    resourcePath: '/api/employees',
    idField: 'id',
    table: {
      resourcePath: '/api/employees',
      columns: [
        { key: 'name', label: 'Name' },
        { key: 'role', label: 'Role' },
      ],
    },
    actions: [
      {
        id: 'create',
        label: 'Create',
        openMode: 'modal',
        form: { formId: 'employee-create', schemaUrl: '/api/employees/schemas/create' },
      },
      {
        id: 'edit',
        label: 'Edit',
        openMode: 'drawer',
        form: { formId: 'employee-edit', schemaUrl: '/api/employees/schemas/edit' },
      },
    ],
  };

  reloadSummary(): void {}
  handleError(error: unknown): void {}
}

Runtime Contract

praxis-crud accepts:

  • metadata: CrudMetadata | string
  • crudId: stable instance id
  • componentInstanceId: optional host-level instance id
  • tableConfigPersistenceStrategy: controls persistence precedence for the internal table. Keep local-first for user-owned workspaces, use input-first for an authoritative governed contract, and use volatile for previews that must neither read nor write stored preferences.
  • context: host/runtime context passed into table and form flows
  • enableCustomization: enables the package authoring surface
  • showResourceIdentityKey: defaults to true; set it to false when an existing-record modal or drawer should preserve the readable identity title without exposing an opaque or technical public key
  • authoringCapability: optionally requires the exact capability from EnterpriseRuntimeContext.capabilities before authoring controls, the CRUD editor, and its internal table authoring are available. This is a UX gate; governed persistence must enforce the same policy server-side.

It emits:

  • configureRequested
  • afterOpen, afterClose, afterSave, afterDelete
  • reactiveDeterminationExecuted (metadata-only evidence with action context), reactiveDeterminationPendingChange, and reactiveDeterminationStabilityChange. The aggregate stability output preserves action and distinguishes pending, settled-satisfied, and settled-unsatisfied; “settled” alone is never a success signal.
  • error
  • resourceEvent is the canonical output for governed host composition and preserves kind, resourcePath, resourceId, payload and runtime context from the internal table. The legacy rowClick, rowAction and selectionChange outputs remain available; rowAction preserves the table envelope (row, action, resourceIdentity) without forcing visual row selection.
  • tableRuntimeConfigChange
  • crudAuthoringDocumentApplied, crudAuthoringDocumentSaved

When visual authoring opens, the CRUD editor is seeded from the latest portable table configuration emitted by the internal table. This keeps the editor aligned with the columns, visibility, ordering, and other runtime settings currently shown to the user instead of reopening an older input snapshot.

When both metadata.resource.path and metadata.data are present, remote resource loading remains authoritative and the editor reports that the local rows are ignored. Use only metadata.data for a local-data preview.

The component keeps crudContext stable for change detection and refreshes the table after successful save/delete flows.

Built-in modal, drawer and nested surface-frame form hosts do not close or complete save while a child Reactive Determination is pending or when its latest generation settled unsatisfied; cancel waits for bounded successful stabilization and stays open on timeout/failure. A custom CrudDrawerAdapter must bind onReactiveDetermination, onReactiveDeterminationPendingChange, and onReactiveDeterminationStabilityChange, use the child Dynamic Form stability API, and refuse every adapter-owned close/submit while pending or unsatisfied. These callbacks are runtime-only and must never be persisted in CRUD metadata.

Metadata Boundaries

resourcePath and resourceKey are intentionally different:

  • resourcePath points to the HTTP resource used by table fetch, schema, submit, and delete flows.
  • resourceKey identifies the semantic resource used by discovery, surfaces, actions, capabilities, and stable component ids.

@praxisui/crud owns CRUD orchestration: list surface, create/edit/view/delete actions, open mode resolution, parameter mapping, initialValue, back policy, and stable handoff to the form host.

For inferred canonical actions, discovery and launcher resolution are sequential decision stages, not equivalent retry mechanisms. The launcher is considered only when discovery did not select an executable surface. Once a governed surface has been selected, a synchronous or asynchronous open failure is fail-closed: the runtime emits the error and a correlated structured diagnostic without attempting a second drawer/modal/route path. A table nested in PraxisCrudComponent always delegates the action to that CRUD owner; a standalone table owns its surface.open attempt and follows the same single-path rule.

Child component semantics stay with their owners:

  • table configuration belongs to @praxisui/table
  • form fields and submit filtering belong to @praxisui/dynamic-form and @praxisui/dynamic-fields
  • dialog presentation belongs to @praxisui/dialog and host adapters
  • page composition belongs to @praxisui/page-builder

Item surfaces published by capabilities are not limited to the conventional create, view, and edit ids. A row may expose domain surfaces such as payroll-history, address, or equipment-custody. The runtime trusts the canonical ResourceSurfaceCatalogItem carried by the row action; if that object is not forwarded, it resolves the exact surface.id through the row HATEOAS links. A surface is distinguished from a business action by its kind and is materialized with the corresponding widget. Related collection surfaces open a governed praxis-table filtered by the selected parent id, while unavailable surfaces remain disabled according to their published availability decision.

Consumers should not translate these domain surfaces into local CRUD aliases or duplicate their filters in the host. The backend owns relationship metadata and availability; @praxisui/core, @praxisui/table, and @praxisui/crud own the materialization and immediate interaction feedback.

Open Modes

CRUD actions can open by route, modal, or drawer. CrudLauncherService resolves the final mode from persisted overrides, action metadata, and global/default configuration.

const editAction: CrudAction = {
  id: 'edit',
  action: 'edit',
  label: 'Edit employee',
  openMode: 'drawer',
};

For canonical create, view, edit, and delete operations, prefer inferred contracts. The runtime resolves the stable form id, request schema, submit URL, and HTTP method from the resource path, capabilities, surfaces, and HATEOAS links. An inferred create/edit action may still author form.submitLabel as a presentation-only affordance. The discovered surface remains authoritative for schema, HTTP operation and endpoint; the CRUD passes the label through the Dynamic Form actions input and suppresses generic cancel/reset duplicates in the standard overlay footer:

const createAction: CrudAction = {
  id: 'create',
  action: 'create',
  label: 'Add skill',
  openMode: 'drawer',
  form: { submitLabel: 'Create skill' },
};

Use an explicit form contract only when the operation deliberately overrides the discovered functional baseline:

const explicitEditAction: CrudAction = {
  id: 'edit',
  action: 'edit',
  label: 'Edit employee',
  openMode: 'drawer',
  formId: 'employee-edit',
  mode: 'explicit',
  form: {
    schemaUrl: '/api/employees/schemas/edit',
    submitUrl: '/api/employees/{id}',
    submitMethod: 'put',
  },
};

Drawer mode works without a host adapter: the package opens its standard form dialog host with drawer presentation. The native drawer remains anchored to its configured viewport edge while applying initial or remembered size and while maximizing or restoring. The default edge is right/top; a configured left edge is preserved by the same lifecycle. defaults.modal.edgeGap controls the viewport inset while maximized. Provide CRUD_DRAWER_ADAPTER only when a host must replace that standard shell with a real corporate drawer implementation. Detached modal and drawer trees retain the injector of the PraxisCrudComponent that initiated the action. This preserves the same scoped GenericCrudService for the hosted Dynamic Form, including host-provided test or tenant adapters, instead of silently creating an unrelated service instance inside the overlay. Upstream surface materializers should publish the semantic openMode and leave geometry absent unless the host intentionally overrides it with a valid DialogPosition object. Values such as position: "end" are not drawer positions and must not be used to imitate a side panel.

For edit drawers and modals, the runtime presents the selected record identity above the command form when the table has materialized it. An explicit valid x-ui.resource.identity remains preferred; otherwise the runtime can expose a key-only fallback from the valid x-ui.resource.idField, without inferring a title from property names. This is read-only context: immutable keys remain outside the request schema and are never added to the update payload. Custom drawer adapters receive the same resourceIdentity context and the showResourceIdentityKey presentation preference. They must keep the identity outside form inputs and retain responsibility for presenting its readable title.

Related operations also receive contextIdentity when the governed surface publishes context.operation.taskScope. The default host labels it as “Related to” and keeps it distinct from the current child record. Both identities stay outside dynamic-form inputs and submit payloads; capabilities and HATEOAS remain the authority for whether the operation may execute.

When a CRUD component is already materialized inside a runtime surface drawer, drawer actions reuse that same session. The launcher calls the active surfaceRuntime.push(...), preserves the live parent frame and returns through the shell Back action instead of stacking a second overlay. The dynamic form registers its dirty-state confirmation through the active frame guard, so Back, Escape, backdrop and shell close share one discard policy. This is runtime-only: the navigation stack, component references and guards must never be serialized into CRUD metadata or x-ui.

SURFACE_DRAWER_REF and SURFACE_DRAWER_CONTENT_DATA are the canonical DI boundary for components hosted in a runtime frame. Hosts should not manufacture an equivalent local token or pass the drawer reference through form values.

Route mode has a different lifecycle because @praxisui/crud does not own the destination component. Routed edit hosts must load the entity through the canonical read contract and materialize identity from its response schema. Do not serialize resourceIdentity into query parameters or treat transient navigation state as reload-safe identity evidence.

Empty collection state

When collection creation is available, PraxisCrudComponent derives the initial empty state for its table from the resource label and the canonical create action. The compact state uses the shared praxis-empty-state-card, repeats the same create label/icon exposed by the toolbar, and invokes the same action path.

Filtered or searched empty results remain distinct and use the table no-results copy without suggesting that the user create a duplicate record. An explicit table.behavior.emptyState always wins, allowing domain-specific read-only or guided states without host CSS or a parallel empty-state component.

Duplicate Draft

PraxisCrudComponent recognizes the canonical optional duplicate-draft operation published by praxis-metadata-starter. When a selected row exposes _links['duplicate-draft'], the runtime:

  1. invokes the non-mutating draft endpoint;
  2. unwraps the returned draft DTO;
  3. opens the canonical create form with that DTO as initialValue;
  4. refreshes the table only after the user saves the new record.

The host does not need to call HttpClient, rebuild create schema URLs, or own a second launcher. Absence of the HATEOAS link keeps the operation unavailable; the runtime does not infer permission from labels, tags, or frontend flags.

Visual Authoring

The package publishes the CRUD-specific authoring surface:

  • CrudAuthoringDocument
  • CrudMetadataEditorComponent
  • PraxisCrudWidgetConfigEditor
  • openCrudMetadataEditor(...)
  • PRAXIS_CRUD_AUTHORING_MANIFEST

Use this authoring surface to edit CRUD orchestration metadata. Do not build local CRUD editors in consuming apps when the needed behavior belongs to the package contract.

AI Authoring

PRAXIS_CRUD_AUTHORING_MANIFEST declares the governed operation families for AI-assisted authoring:

  • resource.bind
  • list.surface.configure
  • surface.create.configure
  • surface.edit.configure
  • surface.view.configure
  • delete.enabled.set
  • dialog.size.set
  • permissions.set
  • form.childOperation.delegate

The manifest owns CRUD orchestration only. It delegates table, form, field, and dialog details to the canonical component owners.

Public API

Main exports:

  • PraxisCrudComponent
  • CrudLauncherService
  • DynamicFormDialogHostComponent
  • CrudMetadata, CrudActionFormContract, CrudDefaults, CrudParamMapping
  • DialogService
  • CrudPageHeaderComponent
  • CrudMetadataEditorComponent
  • PraxisCrudWidgetConfigEditor
  • openCrudMetadataEditor
  • CRUD_AI_CAPABILITIES
  • PRAXIS_CRUD_AUTHORING_MANIFEST

Notes

  • Avoid template getters that create new object/array references for CRUD context.
  • Treat actions[].params and actions[].form.initialValue as input seeding for the launcher, not as automatic persistence rules.
  • Keep UI-only/transient form fields in the dynamic form contract with the appropriate submit policy.
  • Use the official documentation and quickstart for full route, drawer, modal, and backend examples.