All notable changes to @dynamic-entity/core, ngx-dynamic-entity and
ngx-dynamic-entity-builder. The three packages share a version and are released together.
This project follows Semantic Versioning.
Two seams that were configurable in principle and partly ignored in practice: date
formatting reached some surfaces and not others, and a beforeSave veto could be
walked around and could not be observed. Found while wiring the demo to exercise
every documented extension point, which is what made both visible.
saveRejectedonngx-dynamic-record-form. The record editor embedsngx-dynamic-form, so a whole-record save always did runbeforeSaveand always did refuse when the hook said no — but nothing carried that refusal out to a host binding the record editor. The veto worked and was indistinguishable from a Save button that did nothing, which is the exact failuresaveRejectedwas added to prevent in 1.8.x, one layer up.
-
saveSectionno longer bypassesbeforeSave. The record editor saves one tab at a time, and that button emittedsectionSavewithout consulting the hook. The payload isextractRecord()— the whole record, the same object the Save button sends — so identical data reached persistence by two routes, only one of which could be vetoed. A hook registered to refuse a save is a data-integrity mechanism; a second way around it made it advisory. A refused section save now emitssaveRejectedand leaves the section open, so the refused values are still in front of the user.sectionSavestill fires synchronously when no hook is registered. Awaiting an already-resolved promise would have deferred the emit by a microtask for every consumer, to no purpose; only the hook path defers. -
setDateFormattersnow reachesdateanddatetimefields. Both formatted with their owntoLocaleDateString()/toLocaleString()calls rather than through core'sformatDisplayValue, so a host that configured formatters got them in the record summary and ontimefields, and silently did not get them on the two field types most likely to be the reason it configured formatters at all. Both now go through the shared formatter, and are handed the form'slanguage.No visual change without configuration: the default formatters are the same
toLocale*calls these fields made directly. An unparseable stored value is still shown verbatim and never reaches a formatter — records outlive schemas.monthYearis deliberately unchanged. It renders a month name, not a formatted date, and routing it throughformatters.datewould print a day component the field does not have.
No API removed or renamed. DynamicRecordFormComponent.saveSection() now returns
Promise<void> rather than void; callers that ignored the return value are
unaffected.
The npm README pages now document MASKED_PLACEHOLDER and setDateFormatters.
Both shipped in 1.9.0, but only EXTENDING.md mentioned them, and npm does not
show that file. No runtime change.
- Root, core and renderer READMEs cover the masked placeholder, date formatters,
and that
XXXXXXXXXis the default rather than the only text. The core Quick Start comment on catalog length is 21, matching the list above it. - The builder README points those two knobs at the renderer and core, so a reader of that page is not left looking for a builder token that does not exist.
Every word the libraries render themselves is now translatable, and every rendered control has a name and an id of its own. Work since 1.8.1.
-
The libraries' own text is translatable. Field labels, placeholders and options were
LocalizedTextand already followedlanguage; the chrome around them — Save, Reset, "No rows yet.", every tooltip and panel heading in the builder — was English literals in the templates. An application in German rendered German labels around English buttons, and there was no way to change that short of forking a component.The libraries do no translating. They publish the keys they render and resolve whatever the host hands back, per key: 49 keys in the renderer (
UI_TEXT,provideNgxDynamicEntity({ uiText })) and 149 in the builder (BUILDER_TEXT, plus auiLanguageinput onEntityBuilderComponent). A value may beLocalizedText— the same shape a field label uses, resolved against the samelanguage— a flat string, or a resolver(key, defaultText, language) => stringfor a host that already has ngx-translate, Transloco or$localize.DEFAULT_UI_TEXTandDEFAULT_BUILDER_TEXTare exported with their English source strings, so a translation file can be generated rather than transcribed. Anything left out keeps its English default, so an unconfigured install renders exactly what it rendered before.The vocabularies are deliberately separate: an app that ships only the renderer should not see the builder's keys in completion. What they share is
resolveUiText.uiLanguageis notlanguages.languagesis the vocabulary a label is authored in;uiLanguageis the language of the builder's own interface. Tying the chrome to the authoring language would flip the whole panel every time an author switched the label language they were editing. -
The masked placeholder is configurable.
MASKED_PLACEHOLDERreplaces theXXXXXXXXXliteral that was repeated across twenty-one templates. Bullets read as a redaction, a word reads as a permission, and English reads as neither if the app is not in English. Defaults toXXXXXXXXX. -
Date display is configurable.
setDateFormatters({ date, datetime, time })in@dynamic-entity/core. The default remains the browser's locale rather than the form'slanguage:languageselects whichLocalizedTextkey to read, which is a different question from how to punctuate a date, and tying them would silently change the format on upgrade for every consumer whose browser is set to something else. A partial object overrides one kind;setDateFormatters()restores the defaults.
-
Five field types rendered a control with no accessible name.
multiSelect,currency,email,passwordandmonthYeardrew a<label>beside their control without pointing at it, so a screen reader announced an unlabelled combo box and clicking the label focused nothing. The accessibility scan never saw it: it ran over a demo config made of text and dropdowns, and none of the five are in it.monthYearadditionally names each of its two selects, because one label in front of a pair does not say which half the reader has landed on.A per-component sweep now asserts that every rendered control has a name, so a field type added later is covered the day it is registered rather than the day someone points an axe run at a page that happens to contain it.
-
A field rendered twice put duplicate ids on the page. A control's DOM id came straight from
field.id, which is unique in a config and not in a document: anarrayrenders the same child fields once per row, so a two-row Contacts array produced two#nameinputs.<label for>resolves to the first match in document order, so the second row's label focused the first row's input and announced the same association twice — and duplicate ids on focusable elements are a WCAG failure in their own right.Ids now carry a per-instance token:
email-de7. They were never a public contract — address a control through itsdata-testidor its label — and the e2e suite, which had been reaching for#fullName, now does what its own helper file says it should.radioalso gained adata-testidper option, which it had no stable hook for at all. -
A common module registered by selector took its tab down.
CommonModuleEntrytypedcomponentas a string, the token's own example showed one, andCOMMON_MODULES— the catalogue the builder's picker offers — is made of them. The renderer passes that value tongComponentOutlet, which mounts a component type and throws an assertion on a string. Following the documented shape broke the feature, which is worse than the feature not existing.componentis nowstring | ComponentClass, a selector resolves to nothing renderable and warns once naming the fix, and the examples show a class. -
resolveUiTextcould return something that was not text.map[key]walks the prototype chain, so a key oftoStringanswered with a function and__proto__with an object — both of which reached the template. A resolver that threw took the whole form down rather than one label, and a{placeholder}matching an inherited name substituted a function body into a sentence. Keys are typed, but these values arrive from a translation catalogue, a JSON file, or a host written in JavaScript, and none of that is checked at the boundary. -
Configured validation messages now reach every field type. They reached three of fifteen. The other twelve rendered a fixed "This field has an error", so a consumer who configured
validationMessagessaw it on text, number and dropdown and the generic string everywhere else — a documented feature working on a fifth of its surface. Four field types had no error UI at all. A sweep spec walks the real field registry, so a type added later is covered the day it is registered. -
corereports a collection that is present but is not an array.tabs: {}orfields: 'x'was silently ignored rather than reported, which read as an empty entity instead of a malformed one.
1,550 unit tests and 148 E2E, up from 1,337 and 124 at 1.8.1. What is new is the
kind: two source sweeps that assert the published key lists and the templates agree
in both directions; two rendering sweeps that mount every field type and the whole
builder with every key overridden and assert no English default survives in the
markup; a per-component accessible-name sweep; property-based fuzzing of
resolveUiText over 1,500 seeded override shapes per property; and an E2E that
asserts German text does not push the page into a horizontal scroll at 412px. The
three fixes above were all found by one of them.
The demo's reference stylesheet also gained flex-wrap on the tab strip and
shrinkable header rows: a five-tab form was wider than a phone, and the narrow
Playwright project had been rendering that horizontal scroll on every run without
asserting on it.
Only noRows and the field-list wording in the critical-field banner changed shape
internally; rendered text is identical. <strong>Add field</strong> in the builder's
canvas empty state lost its bold — an emphasis span embedded mid-sentence cannot
survive translation, so the sentence is now one key.
Control DOM ids are no longer field.id. An array row repeats the same child
ids, so two #name inputs were on the page and the second row's label focused
the first. Ids now look like email-de7. Address a control through its
data-testid or its label; #fullName is not a contract.
MASKED_PLACEHOLDER, setDateFormatters, UI_TEXT /
provideNgxDynamicEntity({ uiText }) and BUILDER_TEXT / uiLanguage are
additive. An unconfigured install renders what it rendered in 1.8.1, including
the English chrome.
Hardening, almost all of it found by adding property-based fuzzing over core and
integration tests across the three packages.
-
coreno longer throws on a malformed config. Seven crashes, all the same shape:?.guardsundefinedand nothing else, so a config holding a string, a number ornullwhere a collection belongs reached.mapor.forEachand threw.validateConfig,collectFieldScopes,normalizeConfig,normalizeConfigOptions,parseFieldRefandevaluateFormRuleswere all affected — including the validator failing on the input it exists to describe, which tookdynamic-entity validatedown with it in CI.normalizeConfigalso skipped its object-to-array conversion for every falsy non-array, because the guard wasx && !Array.isArray(x). -
The builder no longer saves a config
validateConfigrejects. An entity with no tabs is an error to core — nothing can render — while the builder reported a warning and left Save enabled. The builder now defers to core, in core's wording. -
Focus no longer scrolls the page.
focusActivePanelexists to tell a screen reader the panel changed; the defaultfocus()also scrolls it into view, which jumps the layout on a short viewport. It now passespreventScroll.
BuilderStore.isValid() returns false for an entity with no tabs, where it
returned true. Nothing is stuck: addField creates the first tab when there is
none, so one field is enough. If you drive the builder programmatically and
asserted on the old value, that assertion changes.
1,337 unit tests and 124 e2e across two Playwright projects — the second is a narrow viewport, which the responsive grid collapse never had. Core is fuzzed with 1500 seeded runs per property, and the seed is printed on failure so a red run is reproducible.
Work since 1.7.0: undo and redo in the builder, and three defects found by covering the branches that had none.
-
Undo / redo in the builder. Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z, plus toolbar buttons that disable at the ends of the history.
BuilderStoregainsundo(),redo(),canUndoandcanRedo.History records the
{config, rules}pair — they are two signals, and undoing one without the other could leave a rule pointing at a field that no longer exists. Consecutive edits coalesce inside 400ms when the structure is unchanged, so typing a label is one undo step while a fast double-click on the palette is still two.The shortcut ignores keystrokes aimed at an input, textarea or contenteditable: those have their own undo stack, and hijacking it would discard a structural edit when the author wanted one character back.
-
validateConfigcrashed on a malformed config. Anullinfieldsortabsthrew "Cannot read properties of null" — it failed on exactly the input it exists to describe, and tookdynamic-entity validatedown with it in CI. The main pass guards; the second pass that re-walks the tree for references did not. It now reports the entry and carries on. -
A
datefield showed "Invalid Date".new Date('nonsense')does not throw andtoLocaleDateString()returns that string, so the try/catch meant to fall back to the stored value was dead code. Records outlive schemas — a field retyped from text to date can hold anything — so an unparseable value is now shown as stored. -
Radio options with no value shared one input id. The
?? 'opt'fallback could not fire, because option resolution returns''rather than null. Duplicate ids break theforthat ties each label to its input. -
The form returned to the wrong tab. A form kept mounted while
initialDatawas swapped opened the next record on the previous one's tab. Fixed in two steps: 1.7.0's reset was placed after an early return that a swap could skip, so it is now cleared before that guard.activeTabConfigalready falls back to the first visible tab, which is what makes clearing safe on its own.
Branch coverage rose to 90% in core, 88% in the renderer and 85% in the builder, and the thresholds moved up to hold it. 1,317 unit tests, 102 e2e.
Work since 1.6.0: a second long-form field type, and a builder that will open the per-scope configs the rest of the stack has accepted since 1.4.0.
-
A
markdownfield.textareawas the only long-form input. The important decision is what it stores: markdown source, never HTML, so the record stays plain text — diffable, portable, safe to log, and impossible to turn into stored XSS by writing it to a database.It works with nothing configured — the editor is a textarea, and a read-only view shows the source with its line breaks preserved and nothing interpreted. That default exists because these packages declare no runtime dependencies beyond
tslib, and a markdown parser is a large thing to force on someone who wanted a form library. To render, provideMARKDOWN_RENDERER, in the same shape asUPLOAD_HANDLERand the lookup registries; a Preview tab appears only when one exists, since without it Preview could only echo the source back.Rendered HTML is bound through
[innerHTML], so Angular's sanitizer strips scripts, inline handlers andjavascript:URLs — a backstop, not a licence, and one the specs assert rather than assume. A renderer that throws falls back to the source instead of taking the form down.Field types go from 20 to 21.
FIELD_TYPE_CATALOG, the JSON Schema enum and both documented lists move together.
-
The builder can open a config with the same id in two scopes.
peopleshipspersonal.addressandwork.address; loading it raised "Duplicate field id" and, because an error disables Save, made the whole config a dead end where unrelated edits could not be saved either. Id uniqueness is now counted per scope, using core'scollectFieldScopesrather than a second copy of the rule. Two ids in one scope remain an error — there they share a control and a record key.Relaxing only the check would have turned a safe failure into a corrupting one: every matcher in the store compared bare ids, so
mutateFieldrewrote bothaddressfields on a rename,removeFielddeleted both, andmoveFieldmoved whichever came first. Each now resolves the target once and matches on identity. -
A
markdownfield re-renders when its value changes from outside. Under OnPush the rendered output readcontrol.value, which is neither an input nor a template event, sopatchValue, a rule or anautoPatchmapping left the previous document on screen.
- How a field is addressed.
refererFieldis the substance of 1.4.0 through 1.6.0 and had appeared in no documentation file at all. It now leadsEXTENDING.md: what opens a scope, why[work.address]is the form to write, and the two errors the model prevents.
Work since 1.5.0. The headline is that permissions.edit now means what it says:
it decides whether the fields are editable, not merely whether a Save button is
drawn.
permissions.editis honoured by the fields, not just the actions block. A role outside the edit list received a fully editable form — it could type into every field and only discovered the record was not its to change when no Save button appeared, which is after the typing rather than before.DynamicFormComponent.isFieldReadonlyconsulted thereadonlyinput, the field's own flag,readOnlyFieldsand the critical-field lock, but never the permission. Unlocking acriticalFieldhad the same hole, so a viewer could open the one control that exists to make an edit deliberate.DynamicRecordFormComponentcomputes permissions at all. It declared auserRolesinput and read it from nowhere: noRbacService, no permissions, so the record view was editable for every role. It now resolves them on the same terms as the plain form, cached againstconfiganduserRolesand invalidated when either changes.
A config that declares no permissions is unaffected —
hasPermission(roles, undefined) is true, so the unrestricted case stays
editable. That is the common case, and a unit test pins it alongside the denied
case, the granted case, and re-evaluation after a role switch.
Upgrading: if you relied on RBAC-denied fields staying editable, they are
read-only now. Pass readonly="false" semantics through your own inputs, or
widen permissions.edit, whichever matches what you meant.
The demo is published at
https://berserker5619.github.io/Dynamic-Entity/ and now seeds records for
every entity rather than three of eight — insuranceClaims, the richest config
here, previously opened to an empty list. It also exposes all three record
presentations the renderer supports: the editable form, the record view with its
per-tab "Edit section" flow, and a data-only view (isReadOnly) that had no
route to it, since a role that could edit always saw the Edit section button.
Work since 1.4.0: nothing in the builder names a field by typing any more, the validator understands the paths the builder authors, and the rule editor stopped locking the browser.
validateConfigunderstands field paths, and can check rules. AshowWhenkeyed[work.address], or a cascade parent written the same way, used to be reported as an unknown field — the form the builder authors after 1.4.0. Those paths now resolve to the one field they name. Passrules(or--rules rules.jsonondynamic-entity validate) to apply the same check to a rule's trigger,compareToFieldand field targets; without that option, rules remain an@Inputthe config file cannot see and the renderer still warns in development.
- The
peopleentity in the reference dataset has anaddresson Personal Details and another on Work Details, withdeskNumbershown only when[work.address]isHQ— a config the validator used to refuse, now the fixture the path tests hold. - Every field reference in the builder is now chosen from a list. 1.4.0 covered the
rule form;
showWhen, both ends of apatchOnTruemapping and anautoPatchtarget were still text boxes, and the cascade parent was a list of bare ids. All of them offer the same field paths. Typing was the one way left to author a reference that names two fields at once. - A Tab picker on the field inspector.
moveFieldToTabshipped in 1.4.0 with nothing calling it. Moving a field rewrites its path and repoints the rules that named it. setActiveTab(tabId, { focusPanel: false }). Activating a tab moves focus into its panel, which is right for a keyboard user pressing a tab. A quick-jump also switches tabs and then focuses the field it was aiming at — and the panel focus runs onrequestAnimationFrame, afterafterNextRender, so it landed second and took the focus back. A caller that will focus something more specific can now say so.
- The rule editor locked the browser.
[ngModel]on the targets multi-select was bound to a method returning a fresh array on every call, songModelsaw a new value on each change-detection pass: it wrote, which scheduled another pass, which built another array. Clicking "Rule" froze the page outright. The array identity is now held stable while its contents are unchanged. - A new
showWhencondition seeded the literal stringfield, which is not a field id — so the condition referenced nothing and hid the field until someone noticed. It seeds a real field, falling back to the placeholder only when there is nothing else to watch. - The cascade parent offered the field as its own parent.
- Three capabilities the demo could not reach, and so nothing tested: the builder always
opened a blank entity and can now open any saved one; the record editor — the only host of
the quick-jump links — was never rendered; and no entity marked a field
showOnMinimize.
Work since 1.3.0: a field is now addressed by its path rather than its id, so two
tabs may each have an address; the builder can relocate a field and show the ones
nested in sub-tabs; and rules are chosen from a list instead of typed.
dynamic-entity validate.validateConfigwas an API you had to wrap yourself. The core package now ships a bin that reads a JSON file, prints every problem, and exits 1 when any of them is an error — so a consumer can gate configs in CI withnpx dynamic-entity validate ./form-config.json.--additional-field-typesand--fail-on-warningscover the two options the function already had. This is not a new check; it is the existing one on a command line.- A stated SSR position, and a job that exercises it. The renderer is intended to
work under Angular SSR; the builder is a Material visual editor and is not an SSR
target. CI packs the published tarballs and calls
renderApplicationon Angular 20, so the claim is a passing job rather than the absence ofdocumentaccess. - Zoneless SSR. The renderer does not use
NgZone. CI nowrenderApplications the same form underprovideZonelessChangeDetection()with nozone.json the machine. The demo still loads zone because it is an Angular 17 Material app; that is the demo, not the library. - A field is addressed by its path.
refererFieldnow carries the scopes a field's value nests under, then its id —work.address. A rule or condition names a field by bracketing it,[work.address], and the builder authors that form for every new rule. A bare id still resolves, so every config and rule written before this keeps working; the runtime emits both keys andevaluateFormRulesneeded no change at all. The path is maintained rather than derived: the builder restamps it after each structural edit and repoints the rules that named what moved. ArefererFieldthe config declares is never rewritten — it has always been a binding override, and taking one over as an identity would silently rebind data. moveFieldToTab. The builder could add, remove, duplicate and reorder a field but never relocate one, so a field authored on the wrong tab had to be deleted and rebuilt — losing its validators, options and every rule aimed at it.- Rule fields are chosen, not typed. The rule form had a free-text trigger id and no targets UI at all, so a rule could only ever act on the field it triggered from. Both are now pickers over the config's fields, each option carrying its path, which closes the last route to authoring an ambiguous reference.
- Choice-field
anyis gone.dropdown/radio/multiSelectalready store aDropdownOption(LocalizedText) — the displayed text is the value — so a generic on the field config would have described a contract the library does not have.getOptionStoredValueandresolveOptionValuereturnunknownandstring | number | booleanrather thanany, and the three field components follow. - Field ids are unique per scope, not across the config. A record nests by tab,
the form builds a
FormGroupper tab, andgetControlalready resolved a field in its own tab first — so Personal Details and Work Details could each hold anaddressall along, stored and submitted separately. OnlyvalidateConfigrefused such a config. It now enforces uniqueness within a scope, computed exactly asbuildFormcomputes it: a tab opens one, aflatDatatab shares its parent's, agroupfield opens one for its children. Two fields sharing an id inside one scope is still an error. What cannot be duplicated is an id something points at by bare name:showWhenand cascade parents are reported as ambiguous, and the renderer warns in dev when a rule does the same, since rules arrive as an@Inputthe validator cannot see. - The workspace toolchain moved to Angular 21. The published peer range was already 17–22; only the repo's own build and test stack was still on 17.
- Fields on a sub-tab were invisible in the builder. The canvas read a view that
stopped at top-level tabs — nine of the demo's twenty-eight
insuranceClaimsfields never appeared, and could not be selected or restructured. The same view also fed the entity-reference picker, which could not offer a nested field as a cascade parent, and the drift check, which looked a nested field up, found nothing and returned without checking. Showing them exposed a second defect: drag-and-drop reorders by index and the canvas passed that index with no tab, so it reorderedtabs[0]regardless of what was dragged. The canvas now renders one drop list per tab.
Work since 1.2.0: datetime stopped discarding the time it advertised, time
joined the vocabulary, the quick-jump links started working for the fields they
could never reach, and two accessibility specs that had been skipping themselves
started running.
- A
timefield type. A bare time of day, with no date and no zone.TimeFieldComponentrenders<input type="time">and storesHH:mm— the value the input already reads and writes, so the control binds straight through. This is deliberately notdatetime: a 09:00 opening time is not a moment in time, and storing it as UTC would move it whenever the offset changed. Twenty field types now, one component each.
datetimerendered a date-only input, so editing truncated the time. The type was inRichFieldType, in the published JSON Schema, accepted byvalidateConfig, and offered by the builder palette as "Date & Time — Date and time picker" — and it resolved toDateFieldComponent, whose input istype="date". Saving a record whosedatetimefield held a time silently dropped it. The two display paths disagreed as well:formatDisplayValueshowed the time, the field's own readonly branch did not.DateTimeFieldComponentrendersdatetime-local, stores ISO 8601 UTC, and displays withtoLocaleString(). It reads a legacy date-only value as local midnight, becausenew Date('2020-01-01')is UTC midnight and renders as the previous day west of Greenwich — and every value written by the old input has that shape.- Quick-jump links did nothing for any field in a sub-tab, and never moved
focus.
jumpToFieldsearched top-levelfieldsonly, so a sub-tab field was never found; its target was a plaindiv, soel.focus()was a no-op; and it waited on a 50 mssetTimeoutthat touched an unguardeddocumentand was never cancelled on destroy. It now walks sub-tabs and selects them, schedules withafterNextRender, and the field slot carriestabindex="-1". There is no longer any rawdocumentorwindowaccess in either library.
- Field slots carry
tabindex="-1"so a programmatic jump can focus them. - The builder's per-file coverage floor rose from 76/50/50/79 to 85/75/85/85,
matching the other two packages; global rose to 95/82/97/97. Reaching it meant
first specs for the canvas and tree-node components, edge coverage for the
inspector and rules editor, and deleting a dead
onDrop/fieldTypeLabel/fieldTypeIcon/fieldLabelblock that the canvas extraction had left onEntityBuilderComponent. - Two accessibility specs stopped skipping themselves. Both guarded on what the fixture happened to contain: the tab-focus spec loaded an entity with exactly one tab, so it had never run, and the builder spec needed two rows from a builder that opens empty. The suite is 72 passed, 0 skipped.
1.2.0 — 2026-08-28
Work since 1.1.0: config can be checked before it is stored, a save can be vetoed, referenced-field drift is visible on the form, and the field components stopped re-rendering on every change-detection pass.
validateConfigand a JSON Schema. A config is data, so TypeScript cannot police it — this repository's own dataset shipped field types that do not exist and nothing noticed.validateConfigreports every problem (unknown types, duplicate ids,showWhen/parentFieldpointing nowhere,colSpanoutside the 12-column grid).entity-form-config.schema.jsonships as@dynamic-entity/core/schemafor editor completion.- Async validators and an abortable
beforeSave. Name them withvalidators.customAsyncandprovideNgxDynamicEntity({ asyncValidators }). Pending checks block submit. The${entity}:beforeSavehook can now returnfalseor throw to stop the save;(saveRejected)reports why. - Runtime drift.
hasDriftwas written by the builder and ignored at runtime. A referenced field whose source has changed now shows arole="status"note naming that source. - A stylesheet, a typed field contract, and overridable validation messages.
ngx-dynamic-entity/styles.cssis optional. Custom fields implementDynamicFieldComponentContract. Messages resolve throughValidationMessagesService, overridable per key viaprovideNgxDynamicEntity({ validationMessages }). insuranceClaimsin the demo dataset, with Playwright coverage for the happy path, hostile edges, and composed multi-feature flows.
autoPatchactually appears. Entity-ref selection publishes after the control updates, readonly text tracks the patched value, and hosted fields share the form's selection bus so a concurrent form cannot leak a pick.- The builder cache is dropped on save.
ConfigSourceService.clearCacheexisted and was never called, so a referenced-field lookup after an edit still saw the copy loaded before it. @Input() configis no longer mutated. Normalisation is an accessor over a copy. The builder's label setters copy only the path to the edited field instead of cloning the whole config per keystroke.- core no longer publishes its build toolchain. A derived
distmanifest ships; scripts anddevDependenciesdo not. - Icon-only builder actions have accessible names; builder rows are keyboard
operable; tab switches move focus into a
tabpanel; three contrast failures below WCAG AA are corrected.
- Field components are OnPush. External mutations (
markAllAsTouched,patchForm,autoPatch,patchOnTrue) refresh the hosted component so an OnPush field does not keep showing a stale value.
- CONTRIBUTING, SECURITY.md, issue and PR templates.
mongodb-memory-serverremoved from the root (unused, ~200MB).
1.1.0 — 2026-08-28
The headline of this release is that 1.0.0 could not be installed. Its peer ranges
admitted Angular 17 only, its main field pointed at a path absent from the tarball, and the
builder shipped a wildcard runtime dependency that installed a second copy of the renderer.
Everything below follows from fixing that and then verifying the rest of the claims the
packages were making.
Behaviour changes that can affect an existing app are listed under Changed — most
notably permissions.view is now honoured. Everything else is additive or a bug fix.
No config or record migration is required to move from 1.0.0.
- Angular 17 through 22 are supported. Peer ranges were pinned to
^17.0.0, sonpm installfailed withERESOLVEon any newer Angular. Each major is now verified in CI by installing the packed tarballs and AOT-compiling a consumer component. mainno longer points outside the tarball. A hand-writtenmainwas copied verbatim into the published manifest, where it resolved todist/dist/…. Anything falling back tomain— Jest's resolver, CJSrequire, SSR tooling — could not load the package.- The builder no longer installs a second copy of the renderer.
ngx-dynamic-entitywas declared as both a wildcarddependencyand apeerDependency. Because Angular resolvesInjectionTokenby object reference, a duplicate copy meant registries provided by the host application were invisible to the builder and fields rendered blank. @angular/materialand@angular/cdkare no longer peers ofngx-dynamic-entity, which imports neither. The builder still requires both.sideEffects: falseadded to@dynamic-entity/core.repository,homepage,bugsandauthoradded to all three manifests.
- Submission is blocked while a
validationrule is failing.submit()checked only Angular form validity, while the record editor'ssaveSection()also honoured rule errors — so the same rule blocked one save path and merely showed a banner on the other, and anything wired to(formSubmit)persisted records the rules engine had rejected. - A hidden required field no longer deadlocks the form. Fields hidden by a rule or a
showWhencondition kept their validators, holdingform.invalidtrue forever with the Save button disabled and nothing on screen to explain it. Hidden controls are now disabled, which excludes them from validity while preserving their values and validators. permissions.viewis enforced. It was computed and discarded: a user whose roles failed it still received the complete form with every value in the DOM.- The record editor's summary reads through the tab nesting. It read values flat while the form patched by tab path, so a flat record rendered real values in the summary over a form whose controls were all empty — data loss disguised as a successful load.
- Dot-paths cannot reach an object's prototype.
setValueByPathwalked config-supplied paths with no guard, so arefererFieldof__proto__.isAdminpollutedObject.prototype.__proto__,constructorandprototypeare now refused on both the read and write paths. - Drift detection is key-order independent. It compared with
JSON.stringify, so a config round-tripped through a backend that orders keys differently reported drift on every referenced field. SYSTEM_DEFAULT_CAN_EDITreceives real roles. It was invoked with a hardcoded empty array, so any predicate that inspected roles answeredfalsefor everyone. The token was also declared twice under the same name in two packages; since token identity is by reference, providing the documented one did nothing. There is now a single token.- The email validator no longer collides with
pattern. The builder expressed "email" by writing a regex intovalidators.pattern, so a field could not have both, a custom pattern made the Email box appear ticked, and un-ticking Email deleted the pattern. - Referenced-field drift is checked against the edited field, not whichever field happened to be selected.
- The builder's remove, duplicate, move and reorder now reach fields on sub-tabs, and id uniqueness is validated across the whole tree rather than top-level tabs only.
- Schema migration.
EntityFormConfig.versionandVersionedRecord._configVersionwere declarations nothing read.@dynamic-entity/corenow exportsmigrateRecord,needsMigration,stampRecord,validateMigrationsand theRecordMigrationtype — pure, so the same steps run in a browser and on a server. Register them withprovideNgxDynamicEntity({ migrations })and they are applied where a record enters the form. An unstamped record is deliberately left alone, and a gap in the chain throws rather than half-upgrading. See the README's Schema versioning section. - A dev-mode warning when
initialDatais silently dropped. A record is nested by tab id unless the tab setsflatData: true; passing a flat record to a nested tab populated nothing and reported nothing. The renderer now names the keys that went unused. registerFieldTypeopens the field-type catalog. The lookup index was frozen at module evaluation, so a custom type pushed ontoFIELD_TYPE_CATALOGwas invisible to the builder's palette and tocreateFieldConfig.- All 18 field components are exported. Only 8 were, which defeated the
provideFieldTypes({ … })tree-shaking seam the package documents: wanting eleven of them meant bundling all of them. FieldValidators.emailandFieldValidators.custom. Custom validators registered throughprovideNgxDynamicEntity({ validators })were reachable only from the untypedstring[]form, so naming one from a typed schema required casting toany.DynamicFormComponent.canDelete,canView,ruleValidationErrorsandsubmitBlocked.EntityBuilderComponent.userRoles, distinct fromavailableRoles— who is editing, rather than the role vocabulary a schema may reference.HookFntype, replacingFunctionin the hook registry.
permissions.viewnow hides the form. Previously it was ignored, so a config that set it rendered as though it had not. This is presentational only — masking and permissions stop the browser drawing data, they do not stop it reaching the browser. Authorize on the server.- The builder writes
validators.emailinstead of a regex invalidators.pattern. Configs authored by the previous builder are still recognised, and are migrated as they are edited. ConnectionSourceConfigComponenthas been removed fromngx-dynamic-entity-builder's public API. It wrote aconnectionSourceproperty that is not part ofNestedFieldConfigand that nothing read.
The READMEs described an API the packages did not have, and the Quick Start did not compile.
Removed: the SHOW_WHEN / ENABLE_WHEN / REQUIRE_WHEN / CALCULATE rule types (the real
action types are visibility, validation and info), the READ_WRITE / READ_ONLY /
MASKED / HIDDEN permission levels (the model is view/edit/delete role lists plus
maskData), and "dynamic table rendering" — the package ships no table. Corrected the field
type list (19 types, entity-ref not entityRef), the Quick Start bindings (initialData
and userRoles, not initialValue and role), and FIELD_CATALOG → FIELD_TYPE_CATALOG.
Added sections on record shape, security, styling and schema versioning.
Every fenced code block in every README is now extracted and compiled in CI.
- CI was an empty directory. There are now three workflows: verification (lint, build, test, coverage) plus an Angular 17–22 consumer matrix and a README-snippet compile; a Playwright job; and a tag-driven release that verifies before it publishes and authenticates through npm trusted publishing rather than a long-lived token.
- eslint could not load a TypeScript file —
@typescript-eslint/parserwas declared but never installed — so every rule had been dormant andlinthad quietly becometsc --noEmit. npm run test:coveragehad never passed in any package. It does now, in all three.- Deleted
src/lib/stores/, an abandoned extraction of 162 unreferenced lines. test_data.jsonused three field types that do not exist; the spec that "rendered" it watched only for uncaught exceptions and passed green over them.
Initial public release.