@astrapi69/entity-kit-core
v0.1.0
Published
Framework-agnostic core types, utilities and design tokens for the entity-kit BeanInfo pattern
Maintainers
Readme
@astrapi69/entity-kit-core
Framework-agnostic core for the entity-kit BeanInfo pattern: the types, registry, pure utilities and design tokens that let any object type describe itself once and be rendered, sorted, searched and acted on by generic UI — in any framework.
This package contains zero React, zero JSX, zero UI framework code. It is pure TypeScript plus a set of optional CSS design tokens. Framework bindings build on top of it:
| Package | Role |
| --- | --- |
| @astrapi69/entity-kit-core (this) | Framework-agnostic types, registry, utils, CSS tokens |
| @astrapi69/entity-kit | React components & hooks built on the core |
| @astrapi69/entity-kit-vue (future) | Vue bindings built on the core |
The idea is BeanInfo, from the Java Beans world: an EntityDescriptor<T>
declares how to identify, label, list, search, sort and act on items of T.
Generic components consume the descriptor and never need to know the concrete
type.
Install
npm install @astrapi69/entity-kit-coreNo peer dependencies — nothing to add.
Quick start
import {
type EntityDescriptor,
searchEntities,
sortEntities,
resolveLabel,
descriptorRegistry,
} from "@astrapi69/entity-kit-core";
interface Book {
id: string;
title: string;
author: string;
year: number;
deleted: boolean;
}
// 1. Describe the entity once.
const bookDescriptor: EntityDescriptor<Book> = {
entityName: "book",
displayName: (b) => b.title,
getId: (b) => b.id,
isDeleted: (b) => b.deleted,
listFields: [
{ key: "title", label: () => t("book.title"), sortable: true }, // i18n label
{ key: "author", label: "Author", sortable: true },
{ key: "year", label: "Year", sortable: true },
],
searchableFields: ["title", "author"],
};
// 2. Register it (optional — for decoupled lookup by name).
descriptorRegistry.register(bookDescriptor);
// 3. Use the pure utilities.
const filtered = searchEntities(books, bookDescriptor, "tolkien");
const sorted = sortEntities(filtered, bookDescriptor, "year", "desc");
const header = resolveLabel(bookDescriptor.listFields[0].label);Concepts
Descriptors
A descriptor's required members are entityName, displayName, getId,
listFields and isDeleted. Everything else is optional and defaults are
documented below. Use withDescriptorDefaults to materialise a descriptor with
all optional fields filled in:
import { withDescriptorDefaults } from "@astrapi69/entity-kit-core";
const resolved = withDescriptorDefaults(bookDescriptor);
resolved.detailFields; // [] when omitted
resolved.actions; // [] when omitted
resolved.searchableFields; // [] when omitted
resolved.shortDescription; // () => "" when omitted| Optional field | Default |
| --- | --- |
| shortDescription | () => "" |
| detailFields | [] |
| searchableFields | [] |
| actions | [] |
| thumbnail | undefined |
| deletedAt | undefined |
| icon | undefined |
The Node type parameter (renderable nodes)
EntityDescriptor, FieldDescriptor and ActionDescriptor take an optional
second type parameter Node for renderable things (icon, thumbnail,
render). It defaults to unknown, keeping the core framework-agnostic. A
binding supplies its own node type:
// Inside the React binding:
import type { ReactNode } from "react";
import type { EntityDescriptor as CoreDescriptor } from "@astrapi69/entity-kit-core";
export type EntityDescriptor<T> = CoreDescriptor<T, ReactNode>;i18n labels
FieldDescriptor.label and ActionDescriptor.label are typed as
string | (() => string) (LabelValue). The factory form lets you wire up
i18n. Resolve it with resolveLabel, which invokes the function on every
call — never caches — so labels always reflect the active locale:
resolveLabel("Author"); // "Author"
resolveLabel(() => t("author")); // current translation, re-evaluated each callRegistry
DescriptorRegistry is a Map-keyed store of descriptors by entityName,
decoupling rendering code from definitions. A shared descriptorRegistry
instance is exported for apps that need only one.
registry.register(bookDescriptor);
registry.get<Book>("book"); // throws if not registered
registry.tryGet<Book>("book"); // undefined if not registered
registry.has("book");
registry.list(); // all descriptors, insertion order
registry.names(); // all entity names
registry.unregister("book");
registry.clear();Soft delete / trash
isDeleted(item) drives trash views; deletedAt(item) optionally supplies a
timestamp. The TrashViewOptions.prefiltered flag (defined here, consumed by
bindings) tells a trash view that the items are already the trashed set, so the
internal isDeleted filter is skipped.
API reference
Types
EntityDescriptor<T, Node = unknown>— the self-description of an entity type.FieldDescriptor<T, Node = unknown>— a single field/column.ActionDescriptor<T, Node = unknown>— a single action.RequiredEntityDescriptor<T, Node = unknown>— the required members plus optional rest (input towithDescriptorDefaults).ResolvedEntityDescriptor<T, Node = unknown>— a descriptor with all optional fields filled (output ofwithDescriptorDefaults).LabelValue—string | (() => string).ActionVariant—"default" | "danger".ViewMode—"list" | "tile" | "detail".TrashViewOptions—{ prefiltered?: boolean }.- ClassNames interfaces:
TileClassNames,ListClassNames,DetailClassNames,TrashClassNames,SearchClassNames,ViewSwitcherClassNames,EmptyStateClassNames,ActionsClassNames.
Functions
resolveLabel(label: LabelValue): stringsearchEntities<T>(items, descriptor, query): T[]— case-insensitive substring filter oversearchableFields. Pure; returns a new array.toSearchString(value: unknown): string— the coercion used by search.sortEntities<T>(items, descriptor, key, direction?): T[]— stable sort by a sortable list field; non-sortable keys return an unchanged copy. Pure.isSortable<T>(descriptor, key): booleangenerateTestId(entityName, id, actionId?): string—"book-42"or"book-42-delete".generateSearchTestId(entityName): string—"book-search".generateViewTestId(mode): string—"view-list".withDescriptorDefaults<T>(descriptor): ResolvedEntityDescriptor<T>
Registry
class DescriptorRegistryconst descriptorRegistry: DescriptorRegistry
Styling & theming
The styles live in this package and are entirely optional — bindings render fine with no stylesheet at all (headless-first). Import the full default theme:
import "@astrapi69/entity-kit-core/styles";Or import only the component sheets you need:
import "@astrapi69/entity-kit-core/styles/entity-tile.css";
import "@astrapi69/entity-kit-core/styles/entity-list.css";Every visual property resolves through an --entity-* custom property with a
sensible fallback, named --entity-{component}-{property}, e.g.
var(--entity-tile-bg, #ffffff). There are four ways to theme:
1. CSS Variables (recommended)
Override tokens on :root (or any scope):
:root {
--entity-tile-bg: #fffbe6;
--entity-tile-border: 1px solid #f0d36b;
--entity-radius: 0.75rem;
}Dark mode is built in — set data-theme="dark" on a container or <html>:
<html data-theme="dark">2. Tailwind
Skip the CSS entirely and pass Tailwind classes via the classNames prop your
binding accepts (typed by the ClassNames interfaces exported here):
const classNames: TileClassNames = {
grid: "grid grid-cols-3 gap-4",
tile: "rounded-xl border p-4 shadow",
title: "text-lg font-semibold",
};3. CSS Modules
import styles from "./Books.module.css";
const classNames: ListClassNames = {
table: styles.table,
row: styles.row,
header: styles.header,
};4. CSS-in-JS
Generate class names with your styling library and pass them through the same
classNames interfaces:
const classNames: DetailClassNames = {
container: css({ background: "white", padding: 20 }),
title: css({ fontWeight: 700 }),
};Development
make install # install deps
make check-all # typecheck + lint + test + build
make test # tests only
make build # ESM + CJS + d.ts via tsupLicense
MIT © Asterios Raptis
