@ng-forge/dynamic-forms 1.1.0 → 1.2.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/fesm2022/{ng-forge-dynamic-forms-array-field.component-CvjDqYyq.mjs → ng-forge-dynamic-forms-array-field.component-CSCKq_1K.mjs} +9 -13
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-CSCKq_1K.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-CcoUnroD.mjs → ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-CTOTwzzw.mjs} +29 -7
- package/fesm2022/ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-CTOTwzzw.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-bootstrap-web-mcp-BtjqeJOj.mjs +1054 -0
- package/fesm2022/ng-forge-dynamic-forms-bootstrap-web-mcp-BtjqeJOj.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-container-field.component-DZfVWjiM.mjs → ng-forge-dynamic-forms-container-field.component-Dc3ROhHL.mjs} +14 -17
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-Dc3ROhHL.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-D_yRuLpu.mjs → ng-forge-dynamic-forms-css-wrapper.component-BzgX8lAD.mjs} +5 -5
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-BzgX8lAD.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-debounce-C9F4v1Ke.mjs +17 -0
- package/fesm2022/ng-forge-dynamic-forms-debounce-C9F4v1Ke.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-field-errors-wrapper.component-CIKbWqYA.mjs +25 -0
- package/fesm2022/ng-forge-dynamic-forms-field-errors-wrapper.component-CIKbWqYA.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-form-schema.service-Dx5Dlb15.mjs +24 -0
- package/fesm2022/ng-forge-dynamic-forms-form-schema.service-Dx5Dlb15.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-group-field.component-CPFgu3pM.mjs → ng-forge-dynamic-forms-group-field.component-DAC3P9DC.mjs} +9 -13
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-DAC3P9DC.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs +49 -107
- package/fesm2022/ng-forge-dynamic-forms-integration.mjs.map +1 -1
- package/fesm2022/ng-forge-dynamic-forms-internal.mjs +434 -139
- package/fesm2022/ng-forge-dynamic-forms-internal.mjs.map +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-Du3V5AOb.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-CXQe_5td.mjs} +1930 -1847
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-CXQe_5td.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-amWhCdHL.mjs → ng-forge-dynamic-forms-page-field.component-DPKJcwUO.mjs} +94 -15
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-DPKJcwUO.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-row-wrapper.component-BBWmqQA_.mjs → ng-forge-dynamic-forms-row-wrapper.component-DMEeKZny.mjs} +5 -5
- package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-DMEeKZny.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-schema-builder-BvDTzfdM.mjs +570 -0
- package/fesm2022/ng-forge-dynamic-forms-schema-builder-BvDTzfdM.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-C-f80sbk.mjs → ng-forge-dynamic-forms-text-field.component-BB-jutBq.mjs} +4 -4
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-C-f80sbk.mjs.map → ng-forge-dynamic-forms-text-field.component-BB-jutBq.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms.mjs +2 -2
- package/package.json +2 -2
- package/types/ng-forge-dynamic-forms-integration.d.ts +15 -33
- package/types/ng-forge-dynamic-forms-internal.d.ts +705 -292
- package/types/ng-forge-dynamic-forms.d.ts +400 -46
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-CvjDqYyq.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-bootstrap-derivation-orchestrator-CcoUnroD.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-DZfVWjiM.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-container-utils-BZFz6ztF.mjs +0 -24
- package/fesm2022/ng-forge-dynamic-forms-container-utils-BZFz6ztF.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-D_yRuLpu.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-DhHwU6FJ.mjs +0 -17
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-DhHwU6FJ.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-CPFgu3pM.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-Du3V5AOb.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-page-field.component-amWhCdHL.mjs.map +0 -1
- package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-BBWmqQA_.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-bootstrap-web-mcp-BtjqeJOj.mjs","mappings":";;;;;;;AAmEE;;;AAIA;AAEA;AACE;;;;AAGF;;AAGE;;;;;AAKC;AACD;;;;AAIJ;;ACtEM;AACJ;AACF;AAEA;;AAGE;gBAGE;;AAEA;AACA;AAAY;;AAGd;AACF;AAEA;AACE;;;AAGI;;;;AAKgB;AAEhB;AACA;AAAiC;AACjC;AAAiC;AACjC;;AAEF;;;AAGJ;AAEA;AACE;AACE;;AAEF;AACF;AAEA;;AAGE;;;AAIA;AACA;;AAC6B;;AACA;;AACF;;AACA;;AACA;;AACD;AAE1B;AACF;AAWA;AACE;AACA;AACA;AAEA;AACE;AACA;;AAGF;AAGA;;IAGA;AAA4C;AAE5C;AACF;AAEA;AACE;AACF;AAGA;AACE;;AAEgB;;AACM;;AACoC;AAE1D;AACF;;AC5DM;;AAEJ;AACA;AACF;AAEA;;AAGE;;;;AAIE;;;;AAIA;AACA;;;;AAIE;AACA;AACA;AACA;AACA;AACD;AAED;;;;AAKA;AACE;;;AAGN;AAGA;AACE;;AAEA;AACA;AACA;;;AAIA;AACE;;;;;AAOJ;AAGA;AACE;;AAEE;AAA2B;AAC3B;AACE;;AAEF;AACF;AACF;AAEA;;AAC6D;AAC3D;AAA0B;;AACK;AAC/B;AACF;;;;;ACWE;AACF;AAGA;AAOE;;;;;gBAQE;;AAEE;;;;;;AAMF;AACA;AAAU;;AAEd;AAGA;AACE;AACA;AACA;;;AAGE;;AAEA;;;AAIF;;AAEE;;;AAIF;;;;AAKF;AAYA;;AAEE;AAAyC;AACzC;AAAyC;AACzC;AACF;AAEA;;;;AAG4B;AAE1B;AACA;AAAe;gBAMf;AACE;AAEI;AAEJ;;AAGF;AACF;AAGA;AACE;;AAEE;AACA;;;;AAIoB;AAEtB;AACA;AAAqC;;AAEvC;AAEA;;AAEE;AACF;AAGA;;AAQE;AACE;;AAEF;AACF;AAUA;AACE;AAAuB;AAEvB;AACE;;AAGF;AACA;AACA;AAA2C;;AAG7C;AAEA;AACE;AACE;;AAEA;AACE;AACF;;;AAGJ;AAEA;AACE;AACF;AAEA;AACE;AACA;AACF;AAGA;;AAEE;AACA;AAEA;;AAEE;;AAEE;AACA;AACA;;;AAGA;;;;AAKJ;;AAEI;AACA;;AAEA;AACA;AACA;;;;AAKF;AACA;AACA;AACA;AACA;;;AAGJ;AASA;AACE;AAA4B;AAE5B;;AAEG;;;AAGD;;AAGJ;AAGA;;AAEI;AACE;AACF;AACE;AACF;AACE;AACF;;;AAKJ;AAOA;AACE;AACA;AAAe;AAEf;AAA8B;AAE9B;AACE;;AAGF;AACF;AAGA;AACE;;AACmB;AACnB;AAAuD;AACvD;AAAwD;AACxD;AACF;AAEA;AACE;AACF;AAcA;;;AAGqE;;AACA;AACnE;AAAmC;AACnC;AAAmC;AACnC;;AACK;AAAuC;;AAC3B;;AAGf;;AACA;AAEA;AACE;;AACiC;;AAEjC;;AACiC;;AAEjC;;AACiC;;AAEjC;;AACiC;;AAEjC;;AAC+B;;AACO;;AAEtC;AACE;;;;AAKN;AACF;AAGA;;AAEA;AASA;AACE;;;QAKA;;AAEA;AACA;;;AAIE;;AAEJ;AAMA;AACE;AACF;AAEA;;AACsB;;AACyC;;AACJ;AACzD;AACF;AAGA;AACE;AACF;;ACrfM;AAMJ;;;AAGE;;;;;AAOF;AACF;AAGA;AACE;AACF;AAWM;;AAIN;AAWM;;AAGJ;AACE;AACA;AACA;;;AAEA;AACA;AACE;AACA;;;AAIJ;;AAGA;AACF;AAEA;AACE;AACA;AACE;;;gBAQF;AACA;AACE;;;;AAQF;AACE;;;;AAKF;AACE;;;AAGJ;AAEA;AACE;AACE;AACA;AACA;;;;;AAQA;AACA;;;AAIF;AACE;AACA;;AAEJ;AAEA;AACE;AACF;AAaM;AACJ;AACE;AACE;AAEF;AACE;AAEF;;AAGA;AACE;AAMF;;AAGA;;AAOA;AACE;AAEF;AACE;;AAEN;AAEA;AACE;AAEA;;;AAIA;AACA;AACA;AACF;AAEA;AACE;AACE;;AAEJ;AAGA;;AAEE;AACF;;ACjMA;AASA;;;;;AAmBoC;;;AAGC;AAEnC;AACF;AAEA;AACE;AACF;AAGM;;;;AAIJ;AACF;AAGM;AACJ;AACF;AAaO;;AAML;;;;AAIA;;AAEE;;;;;AAIJ;;AC/GA;AAwBM;;AAEF;;AAGF;;;;;AAMA;;AAGF;AAEA;;;AAYE;AACE;;;AAIE;AACA;;;AAIF;AACE;;;;;;;;;AAWF;AACE;gDAKA;AAA2B;;;AAI/B;AACF;AAEA;AASE;AACE;;;AAIE;AACE;AACA;;AAEF;AACA;AACA;;;;AAKE;AACA;;AAEF;AACE;AACA;;;AAGF;AACE;AAEI;AAEJ;;AAGF;;AAIA;AACA;;;AAGN;AAEA;AAQE;AACE;;AAGF;AACE;AACA;;gBAOF;AACF;AAEA;AACE;;AAEI;AACA;;AAEF;AACA;;AAEE;;AAEF;;AAGF;;AAEI;AACA;;AAEF;;;;AAKA;;AAGF;AACF;AAEA;;AAEA;AAEA;AACE;AACF;AAEA;;AAEI;AACE;AACF;;AAEA;;AAEA;AACE;AACF;;;AAGJ;AAGA;;AACsB;;AACK;AACzB;AAA0B;;AACK;;AACA;AAC/B;AACF;AAsBA;AACE;AAA0E;AAE1E;AAEA;;;AAIA;AACF;AAEA;AACE;;AAEA;AACE;;AAGA;;;;QAMA;AACE;;;wBAOF;AACE;;;AAGN;AAEA;AACE;AACE;AACE;AAAsD;;;;;AAM5D;AASA;;AAC6D;AAC3D;AAA0B;AAC1B;AACF;AAEA;AACE;AACF;;;;AC7SE;AAEA;;;;AAKI;;;AAIF;;AAGF;AACF;AAWO;AAGD;AACJ;AAA2B;;AAI3B;AACE;;AAEA;AACE;;;AAIF;;AAGF;AACF;AAEA;AACE;AACE;;AAEA;AACE;AAA2D;;AAE7D;AACE;;AAEN;AAeM;AACJ;AAA2B;;AAI3B;AACE;AACA;;AAEA;;;;AAKA;AACA;;AAGF;AACF;AAEA;AACE;AACF;;ACvGA;AASA;AASA;AAGA;AA0BO;AACL;AACA;AACA;AAEA;;AAEE;AACA;;AAGF;AAEA;AACA;AACA;AAEA;YAKA;AAEA;AACE;AACE;;;;;;;AAQA;AACD;;oBAQH;gCAKE;;AAGE;;;;AAKE;AACE;;;;AAcJ;AACD;;;AAKH;AACE;AACE;AAEI;;;;AAKU;;;AAGE;AAEpB;AACE;AACA;;AAGF;AAEA,IASA;AACE;AACA;AAAW;;;;AAKX;;AAGF,IAQA;AACE;AACA;AAAW;;;;AAKX;;;;AAKA;wBAKA;;IAIF;AACE;;;AASF;AAWE;gBAIA;AAA2C;;AAG3C;AACA;AAEA;;oCAME;AAAyB;AAEzB;;;AAEA;AACA;;;AAIJ,IAIA;gCASE;AAA2C;gCAM3C;AACA;;;AASA;AACA;;;AAIA;AACA;AAEA;;;AAIA;;AAME;;;;;;;;IAeJ;;;AAEuB;;AACA;AACrB;;AAGF,IAUA;;;AAKE;AACE;AAAqE;AACrE;;AAGF;;AAEJ;AAWA;AACE;AACF;AAEA;AACE;AACF","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/utils/submission-handler/submission-outcome.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/build-tool-schema.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/collect-field-reports.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/field-plan.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/format-report.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/model-context.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/parse-agent-input.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/patch-values.ts","../../../../packages/dynamic-forms/src/lib/core/web-mcp/bootstrap-web-mcp.ts"],"sourcesContent":["/**\n * How a submission ended.\n *\n * Dispatching `FormSubmitEvent` is fire-and-forget: the submission pipeline\n * drops a second submission through `exhaustMap`, skips submission entirely when\n * the form is invalid or async validators are still pending, and swallows an\n * asynchronous action failure to keep the stream alive. None of that is visible\n * to the caller, which is fine for a button click — the user is watching the\n * page — and wrong for anything that has to report back, an agent tool call\n * above all.\n *\n * @experimental\n */\nexport type SubmissionOutcome =\n | { readonly status: 'success' }\n /** No `submission.action` is configured; the `(submitted)` output received the value. */\n | { readonly status: 'dispatched' }\n /** The form was invalid, so nothing was submitted. */\n | { readonly status: 'validation-failed' }\n /** Async validators had not resolved, so submission was skipped. */\n | { readonly status: 'pending-validation' }\n /** The action ran and came back with server-side validation errors. */\n | { readonly status: 'server-errors' }\n /** The action threw or rejected. */\n | { readonly status: 'action-failed'; readonly error: unknown }\n /** Another submission was already in flight and this one was dropped. */\n | { readonly status: 'busy' }\n /** The form went away before the submission settled. */\n | { readonly status: 'cancelled' };\n\n/**\n * The reply channel a caller attaches to `FormSubmitEvent` when it needs to know\n * what happened.\n *\n * `accept()` is called by the submission handler the moment it takes the event.\n * Because the event bus dispatches synchronously, a caller that finds its reply\n * un-accepted once `dispatch()` returns knows the pipeline dropped the\n * submission rather than merely not having finished it yet — which is the only\n * way to tell \"busy\" apart from \"still running\".\n *\n * @experimental\n */\nexport interface SubmissionReply {\n /** Marks the submission as taken by the pipeline. */\n accept(): void;\n /** Reports the final outcome. The first call wins; later calls are ignored. */\n settle(outcome: SubmissionOutcome): void;\n}\n\n/** A reply channel plus the promise that resolves with its outcome. */\nexport interface PendingSubmission {\n readonly reply: SubmissionReply;\n /** True once the pipeline has taken the event. */\n readonly accepted: () => boolean;\n readonly outcome: Promise<SubmissionOutcome>;\n /** Settles as `cancelled` if nothing else has settled yet. */\n cancel(): void;\n}\n\n/**\n * Creates a one-shot reply channel for a single submission.\n *\n * @internal\n */\nexport function createPendingSubmission(): PendingSubmission {\n let settled = false;\n let accepted = false;\n let resolve!: (outcome: SubmissionOutcome) => void;\n\n const outcome = new Promise<SubmissionOutcome>((resolvePromise) => {\n resolve = resolvePromise;\n });\n\n const settle = (result: SubmissionOutcome): void => {\n if (settled) return;\n settled = true;\n resolve(result);\n };\n\n return {\n reply: {\n accept: () => {\n accepted = true;\n },\n settle,\n },\n accepted: () => accepted,\n outcome,\n cancel: () => settle({ status: 'cancelled' }),\n };\n}\n","import type { ItemPlan, PlanNode, ScalarPlan } from './field-plan';\nimport type { JsonSchema, JsonSchemaLiteral, JsonSchemaObject, JsonSchemaScalarType } from './json-schema';\n\n/**\n * Builds a WebMCP tool input schema from a form's {@link PlanNode | field plan}.\n *\n * Deliberately config-driven rather than value-driven. Angular's own\n * `inferSchemaFromFieldNode` walks runtime values and bails (throwing) on\n * `null`, `undefined` and empty arrays — all of which ng-forge produces by\n * default for `nullable` fields and empty array fields. Reading the config\n * instead sidesteps that entirely and carries far more for the agent: labels,\n * option enums with their human titles, and static validator constraints.\n *\n * The schema describes a **patch**, so it emits no `required` at any level and\n * fields an agent may not write are left out entirely. What is required right\n * now, and which fields currently apply, come back from `fill` instead — those\n * depend on live state and would be wrong the moment an agent cached them.\n *\n * @internal\n */\nexport function buildToolSchema(plan: readonly PlanNode[]): JsonSchemaObject {\n return { type: 'object', properties: buildProperties(plan), additionalProperties: false };\n}\n\nfunction buildProperties(nodes: readonly PlanNode[]): Record<string, JsonSchema> {\n const properties: Record<string, JsonSchema> = {};\n\n for (const node of nodes) {\n // A field the agent cannot write has no place in an input schema. Offering\n // it would only invite a call the parser then rejects.\n if (!node.policy.writable) continue;\n\n const schema = buildNodeSchema(node);\n if (schema) properties[node.key] = schema;\n }\n\n return properties;\n}\n\nfunction buildNodeSchema(node: PlanNode): JsonSchema | undefined {\n switch (node.kind) {\n case 'group': {\n const properties = buildProperties(node.children);\n return annotate(node, { type: 'object', properties, additionalProperties: false });\n }\n case 'array': {\n // No derivable item shape means the parser will reject any write to it,\n // so the property is left out rather than advertised as settable.\n if (!node.item) return undefined;\n\n const schema: JsonSchema = { type: 'array', items: buildItemSchema(node.item) };\n if (node.minItems !== undefined) schema.minItems = node.minItems;\n if (node.maxItems !== undefined) schema.maxItems = node.maxItems;\n return annotate(node, schema);\n }\n case 'scalar':\n return annotate(node, buildScalarSchema(node));\n }\n}\n\nfunction buildItemSchema(item: ItemPlan): JsonSchema {\n if (item.kind === 'object') {\n return { type: 'object', properties: buildProperties(item.children), additionalProperties: false };\n }\n return buildScalarSchema(item.value);\n}\n\nfunction buildScalarSchema(plan: ScalarPlan): JsonSchema {\n const base: JsonSchema = plan.options ? optionSchema(plan) : { type: singleOrUnion(plan.types) };\n\n if (plan.multiple) {\n return { type: 'array', items: base };\n }\n\n const withConstraints: JsonSchema = { ...base };\n const { minLength, maxLength, minimum, maximum, pattern, format } = plan.constraints;\n if (minLength !== undefined) withConstraints.minLength = minLength;\n if (maxLength !== undefined) withConstraints.maxLength = maxLength;\n if (minimum !== undefined) withConstraints.minimum = minimum;\n if (maximum !== undefined) withConstraints.maximum = maximum;\n if (pattern !== undefined) withConstraints.pattern = pattern;\n if (format !== undefined) withConstraints.format = format;\n\n return withConstraints;\n}\n\n/**\n * Describes an option-bearing field as both `enum` and `anyOf` of titled\n * `const`s, the way WebMCP's declarative API describes a `<select>`.\n *\n * `enum` alone gives the agent the machine values and nothing else, which is\n * unusable when those values are country codes, SKUs or opaque ids. The `anyOf`\n * branches carry the label the human sees, so the agent can tell what it is\n * choosing between.\n */\nfunction optionSchema(plan: ScalarPlan): JsonSchema {\n const options = plan.options ?? [];\n const values = options.map((option) => option.value as JsonSchemaLiteral);\n const nullable = !plan.multiple && plan.types.includes('null');\n\n const schema: JsonSchema = {\n type: singleOrUnion(plan.types),\n enum: nullable ? [...values, null] : values,\n };\n\n const branches: JsonSchema[] = options.map((option) =>\n option.title ? { const: option.value as JsonSchemaLiteral, title: option.title } : { const: option.value as JsonSchemaLiteral },\n );\n if (nullable) branches.push({ const: null });\n\n // Only worth the extra bytes when at least one option carries a real label.\n if (options.some((option) => option.title)) schema.anyOf = branches;\n\n return schema;\n}\n\nfunction singleOrUnion(types: readonly JsonSchemaScalarType[]): JsonSchemaScalarType | readonly JsonSchemaScalarType[] {\n return types.length === 1 ? types[0] : types;\n}\n\n/** Attaches the field's human-facing title, description and declared default. */\nfunction annotate(node: PlanNode, schema: JsonSchema): JsonSchema {\n const annotated: JsonSchema = { ...schema };\n\n if (node.label) annotated.title = node.label;\n if (node.description) annotated.description = node.description;\n if (node.kind === 'scalar' && node.default !== undefined) annotated.default = node.default;\n\n return annotated;\n}\n","import { ValidationMessages } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldTree } from '@angular/forms/signals';\nimport type { ArrayPlan, OptionPlan, PlanNode } from './field-plan';\n\n/** One field's current state, as reported to an agent. */\nexport interface FieldReport {\n /** Dotted path into the form value. */\n path: string;\n /** The field's label, when it has a static one. */\n label?: string;\n /** Currently applicable — not hidden and not disabled. */\n applicable: boolean;\n /** Required right now, including by conditional validators. */\n required: boolean;\n /** Whether an agent may write this field. */\n writable: boolean;\n /** Whether an agent may see this field's value. */\n readable: boolean;\n /**\n * Whether the field currently holds a value.\n *\n * This is what lets an agent orient itself — see what is still blank and what\n * is already answered — without the response handing back every value on the\n * page.\n */\n filled: boolean;\n /** Allowed values, for fields that declare options. */\n options?: readonly OptionPlan[];\n}\n\n/** Live per-field state the runtime write policy consults. */\nexport interface LiveFieldState {\n disabled: boolean;\n readonly: boolean;\n hidden: boolean;\n}\n\n/** A field walk's output: per-field rows plus subtree- and path-keyed indexes. */\nexport interface FieldWalk {\n reports: FieldReport[];\n /**\n * Maps each field subtree to its dotted path.\n *\n * Signal Forms' public `ReadonlyFieldState` exposes `name` (a display\n * identifier) but not a structural path, so error rows are resolved back to\n * config paths through this index rather than by reaching into internals.\n */\n paths: Map<unknown, string>;\n /** Maps each field subtree to its declared `validationMessages`. */\n messages: Map<unknown, ValidationMessages>;\n /** Maps a dotted path to what the form currently allows on it. */\n state: Map<string, LiveFieldState>;\n}\n\n/**\n * Walks the field plan alongside a live field tree, producing one row per keyed\n * field with its *current* applicability and requiredness.\n *\n * This is the half of the agent's picture the static schema deliberately does\n * not carry: `hidden`, `disabled` and conditionally-`required` all depend on\n * live form state, so they are reported at call time rather than frozen into a\n * schema the agent may have cached.\n *\n * Array descendants are walked too. Leaving them out meant an error inside an\n * array item resolved to no path and no message, which is precisely the case an\n * agent most needs spelled out.\n *\n * @internal\n */\nexport function collectFieldReports(plan: readonly PlanNode[], tree: FieldTree<unknown>): FieldWalk {\n const walkResult: FieldWalk = { reports: [], paths: new Map(), messages: new Map(), state: new Map() };\n walk(plan, tree, walkResult);\n return walkResult;\n}\n\nfunction walk(nodes: readonly PlanNode[], tree: FieldTree<unknown>, out: FieldWalk): void {\n const record = tree as unknown as Record<string, FieldTree<unknown> | undefined>;\n\n for (const node of nodes) {\n const subtree = record[node.key];\n if (typeof subtree !== 'function') continue;\n\n const state = subtree();\n\n out.paths.set(subtree, node.path);\n if (node.messages) out.messages.set(subtree, node.messages);\n out.state.set(node.path, { disabled: state.disabled(), readonly: state.readonly(), hidden: state.hidden() });\n out.reports.push({\n path: node.path,\n label: node.label,\n applicable: !state.hidden() && !state.disabled(),\n required: state.required(),\n writable: node.policy.writable,\n readable: node.policy.readable,\n filled: hasValue(state.value()),\n options: node.kind === 'scalar' ? node.options : undefined,\n });\n\n if (node.kind === 'group') {\n walk(node.children, subtree, out);\n continue;\n }\n\n if (node.kind === 'array') {\n walkArray(node, subtree, out);\n }\n }\n}\n\n/** Walks each live item of an array against the array's item plan. */\nfunction walkArray(node: ArrayPlan, subtree: FieldTree<unknown>, out: FieldWalk): void {\n if (node.item?.kind !== 'object') return;\n\n const items = subtree() as unknown as { value: () => unknown };\n const value = items.value();\n if (!Array.isArray(value)) return;\n\n const indexed = subtree as unknown as Record<number, FieldTree<unknown> | undefined>;\n\n for (let index = 0; index < value.length; index += 1) {\n const itemTree = indexed[index];\n if (typeof itemTree !== 'function') continue;\n\n // Item plans are built from the template, so their paths read `items[]`.\n // Re-anchor them onto the real index the agent would have to send back.\n walk(rebase(node.item.children, `${node.path}[${index}]`), itemTree, out);\n }\n}\n\n/** Re-roots a template's plan nodes onto a concrete array index. */\nfunction rebase(nodes: readonly PlanNode[], prefix: string): readonly PlanNode[] {\n return nodes.map((node) => {\n const path = `${prefix}.${node.key}`;\n if (node.kind === 'group') return { ...node, path, children: rebase(node.children, path) };\n if (node.kind === 'array' && node.item?.kind === 'object') {\n return { ...node, path, item: { kind: 'object' as const, children: rebase(node.item.children, `${path}[]`) } };\n }\n return { ...node, path };\n });\n}\n\nfunction hasValue(value: unknown): boolean {\n if (value === null || value === undefined || value === '') return false;\n if (Array.isArray(value)) return value.length > 0;\n if (typeof value === 'number') return !Number.isNaN(value);\n return true;\n}\n","import {\n FieldDef,\n FieldOption,\n FieldScope,\n FieldTypeDefinition,\n FieldWebMcpOptions,\n getFieldValueHandling,\n ValidationMessages,\n} from '@ng-forge/dynamic-forms/internal';\nimport { getNormalizedArrayMetadata } from '../../utils/array-field/normalized-array-metadata';\nimport type { JsonSchemaScalarType } from './json-schema';\n\n/**\n * A config-derived description of everything the WebMCP layer needs to know\n * about a form's shape.\n *\n * Built once per registration epoch and consumed by four things that must agree\n * with each other: the tool schema handed to the agent, the runtime parser that\n * validates the agent's arguments, the patch merge that writes them, and the\n * redaction that decides what comes back. Deriving all four from one walk is\n * what keeps the schema an agent reads and the rules the form enforces from\n * drifting apart.\n *\n * @internal\n */\nexport type PlanNode = ScalarPlan | GroupPlan | ArrayPlan;\n\n/** What an agent may do with one field. */\nexport interface PlanPolicy {\n /** The agent may see this field's value in a tool response. */\n readable: boolean;\n /** The agent may write this field. */\n writable: boolean;\n}\n\ninterface PlanBase {\n key: string;\n /** Dotted path from the form root, for messages and reports. */\n path: string;\n label?: string;\n description?: string;\n policy: PlanPolicy;\n /**\n * The field's declared `validationMessages`.\n *\n * ng-forge resolves messages per field at render time, so a raw\n * `ValidationError` carries no message. Carrying them on the plan lets an\n * error row use the message the author already wrote for humans.\n */\n messages?: ValidationMessages;\n}\n\n/** One allowed value of an option-bearing field, with the human label that explains it. */\nexport interface OptionPlan {\n value: unknown;\n title?: string;\n}\n\n/** Static constraints lifted from the field's validators. */\nexport interface PlanConstraints {\n minLength?: number;\n maxLength?: number;\n minimum?: number;\n maximum?: number;\n pattern?: string;\n format?: string;\n}\n\n/** A leaf field holding a single value (or, for a multi-select, an array of them). */\nexport interface ScalarPlan extends PlanBase {\n kind: 'scalar';\n /** Accepted JSON types, including `'null'` when the field is nullable. */\n types: readonly JsonSchemaScalarType[];\n /** Allowed values, for fields declaring `options`. Disabled options are excluded. */\n options?: readonly OptionPlan[];\n /** True when the value is an array of option values rather than one of them. */\n multiple: boolean;\n /** Declared default, when it is a JSON primitive. */\n default?: string | number | boolean | null;\n constraints: PlanConstraints;\n}\n\n/** A nested object level. */\nexport interface GroupPlan extends PlanBase {\n kind: 'group';\n children: readonly PlanNode[];\n}\n\n/** A list. Items are homogeneous — see {@link ItemPlan}. */\nexport interface ArrayPlan extends PlanBase {\n kind: 'array';\n /** Shape of one item, from the array's template. Undefined when it could not be derived. */\n item: ItemPlan | undefined;\n minItems?: number;\n maxItems?: number;\n}\n\n/** One array item: either an object of fields, or a bare value. */\nexport type ItemPlan = { kind: 'object'; children: readonly PlanNode[] } | { kind: 'value'; value: ScalarPlan };\n\n/** Reports a field the plan builder had to drop. Kept injectable so the builder stays pure. */\nexport type PlanWarn = (message: string) => void;\n\n/** A field that may carry validators (declared on value-bearing fields only). */\ninterface FieldWithValidators {\n validators?: readonly ValidatorLike[];\n}\n\n/** The subset of `BuiltInValidatorConfig` the plan reads. */\ninterface ValidatorLike {\n type: string;\n value?: unknown;\n expression?: string;\n when?: unknown;\n}\n\n/** Fields the builder reads off a definition, none of them type-level guarantees. */\ninterface FieldLike {\n key?: string;\n type: string;\n label?: unknown;\n value?: unknown;\n nullable?: boolean;\n readonly?: boolean;\n derivation?: string;\n logic?: readonly { type?: string }[];\n placeholder?: unknown;\n props?: { type?: string; placeholder?: unknown; hint?: unknown };\n options?: readonly FieldOption<unknown>[];\n fields?: unknown;\n minLength?: number;\n maxLength?: number;\n min?: number;\n max?: number;\n pattern?: string | RegExp;\n email?: boolean;\n required?: boolean;\n validationMessages?: ValidationMessages;\n webMcp?: FieldWebMcpOptions | false;\n}\n\n/**\n * Walks a form's field definitions and produces the plan.\n *\n * @param fields - Top-level field definitions of the form.\n * @param registry - Field type registry, read for `valueHandling` and `scope`.\n * @param warn - Sink for dropped-field diagnostics.\n *\n * @internal\n */\nexport function buildFieldPlan(\n fields: readonly FieldDef<unknown>[],\n registry: Map<string, FieldTypeDefinition>,\n warn?: PlanWarn,\n): readonly PlanNode[] {\n const nodes: PlanNode[] = [];\n collectInto(nodes, fields, registry, '', warn);\n return nodes;\n}\n\n/** Walks a sibling list, merging flattened containers' children into the same level. */\nfunction collectInto(\n out: PlanNode[],\n fields: readonly FieldDef<unknown>[],\n registry: Map<string, FieldTypeDefinition>,\n prefix: string,\n warn?: PlanWarn,\n): void {\n for (const field of fields) {\n const valueHandling = getFieldValueHandling(field.type, registry);\n if (valueHandling === 'exclude') continue;\n\n const candidate = field as unknown as FieldLike;\n\n // Rows, pages and containers are presentational: their children live at the\n // parent's level. This is why a paged form is a single flat plan.\n if (valueHandling === 'flatten') {\n const children = asFieldArray(candidate.fields);\n if (children) collectInto(out, children, registry, prefix, warn);\n continue;\n }\n\n if (!candidate.key) continue;\n\n const node = buildNode(candidate, registry, prefix, warn);\n if (node) out.push(node);\n }\n}\n\n/** Builds the plan node for one keyed, value-bearing field. */\nfunction buildNode(field: FieldLike, registry: Map<string, FieldTypeDefinition>, prefix: string, warn?: PlanWarn): PlanNode | undefined {\n const key = field.key as string;\n const path = prefix ? `${prefix}.${key}` : key;\n const base: PlanBase = {\n key,\n path,\n label: staticText(field.label),\n description: staticText(field.placeholder) ?? staticText(field.props?.placeholder) ?? staticText(field.props?.hint),\n policy: resolvePolicy(field),\n messages: field.validationMessages,\n };\n\n if (field.type === 'group') {\n const children: PlanNode[] = [];\n collectInto(children, asFieldArray(field.fields) ?? [], registry, path, warn);\n return { ...base, kind: 'group', children };\n }\n\n if (field.type === 'array') {\n return buildArrayNode(field, base, registry, warn);\n }\n\n return buildScalar(field, base, registry);\n}\n\n/**\n * Builds the plan for an array field from its *template* rather than from the\n * items that happen to exist right now.\n *\n * A simplified array stores its template as normalization metadata, so an array\n * declared with `value: []` has a perfectly well-known item shape even though\n * `fields` is empty. Reading the instances instead is what made empty arrays\n * disappear from the schema, and it also made two structurally identical items\n * with different defaults look heterogeneous.\n */\nfunction buildArrayNode(field: FieldLike, base: PlanBase, registry: Map<string, FieldTypeDefinition>, warn?: PlanWarn): ArrayPlan {\n const plan: ArrayPlan = { ...base, kind: 'array', item: buildItemPlan(field, base.path, registry, warn) };\n if (typeof field.minLength === 'number') plan.minItems = field.minLength;\n if (typeof field.maxLength === 'number') plan.maxItems = field.maxLength;\n return plan;\n}\n\nfunction buildItemPlan(field: FieldLike, path: string, registry: Map<string, FieldTypeDefinition>, warn?: PlanWarn): ItemPlan | undefined {\n const template = getNormalizedArrayMetadata(field as object)?.template;\n const source = template ?? firstDeclaredItem(field);\n if (source === undefined) return undefined;\n\n const itemPlan = itemPlanFrom(source, path, registry, warn);\n if (!itemPlan) return undefined;\n\n // A full-API array declares each item separately, so its items can differ by\n // position. `items` is a single schema and the inference layer WebMCP vendors\n // ignores `prefixItems`, so ng-forge supports homogeneous arrays only: warn\n // and drop the item shape rather than describing only the first item.\n if (!template && !itemsAreHomogeneous(field, path, registry, itemPlan, warn)) {\n warn?.(\n `WebMCP: array field \"${field.key}\" declares items with different shapes. ` +\n `Only homogeneous arrays can be described to an agent, so its items are left unspecified.`,\n );\n return undefined;\n }\n\n return itemPlan;\n}\n\n/** Turns one item definition — a field list (object item) or a bare field (value item) — into a plan. */\nfunction itemPlanFrom(source: unknown, path: string, registry: Map<string, FieldTypeDefinition>, warn?: PlanWarn): ItemPlan | undefined {\n if (Array.isArray(source)) {\n const children: PlanNode[] = [];\n collectInto(children, source as readonly FieldDef<unknown>[], registry, `${path}[]`, warn);\n return { kind: 'object', children };\n }\n\n const itemField = source as FieldLike;\n if (!itemField?.type) return undefined;\n\n const node = buildNode(itemField, registry, `${path}[]`, warn);\n if (!node || node.kind !== 'scalar') return undefined;\n return { kind: 'value', value: node };\n}\n\nfunction firstDeclaredItem(field: FieldLike): unknown {\n const items = Array.isArray(field.fields) ? (field.fields as unknown[]) : undefined;\n return items?.length ? items[0] : undefined;\n}\n\n/** Compares every declared item against the first one's plan. */\nfunction itemsAreHomogeneous(\n field: FieldLike,\n path: string,\n registry: Map<string, FieldTypeDefinition>,\n first: ItemPlan,\n warn?: PlanWarn,\n): boolean {\n const items = Array.isArray(field.fields) ? (field.fields as unknown[]) : [];\n return items.every((item) => {\n const candidate = itemPlanFrom(item, path, registry, warn);\n return candidate !== undefined && sameShape(candidate, first);\n });\n}\n\n/**\n * Structural comparison of two item plans.\n *\n * Deliberately ignores defaults, labels and descriptions: two items differing\n * only in their prefilled value describe the same shape to an agent, and\n * comparing whole objects (which is what the previous `deepEqual` over built\n * schemas did) reported those as heterogeneous.\n */\nfunction sameShape(a: ItemPlan, b: ItemPlan): boolean {\n if (a.kind !== b.kind) return false;\n\n if (a.kind === 'value' && b.kind === 'value') {\n return scalarShape(a.value) === scalarShape(b.value);\n }\n\n const aChildren = (a as { children: readonly PlanNode[] }).children;\n const bChildren = (b as { children: readonly PlanNode[] }).children;\n if (aChildren.length !== bChildren.length) return false;\n\n return aChildren.every((child, index) => nodeShape(child) === nodeShape(bChildren[index]));\n}\n\nfunction nodeShape(node: PlanNode): string {\n switch (node.kind) {\n case 'scalar':\n return `${node.key}:${scalarShape(node)}`;\n case 'group':\n return `${node.key}:group(${node.children.map(nodeShape).join(',')})`;\n case 'array':\n return `${node.key}:array(${node.item ? itemShape(node.item) : '?'})`;\n }\n}\n\nfunction itemShape(item: ItemPlan): string {\n return item.kind === 'value' ? scalarShape(item.value) : item.children.map(nodeShape).join(',');\n}\n\nfunction scalarShape(plan: ScalarPlan): string {\n const options = plan.options ? plan.options.map((option) => JSON.stringify(option.value)).join('|') : '';\n return `${plan.types.join('/')}${plan.multiple ? '[]' : ''}${options ? `{${options}}` : ''}`;\n}\n\n/** Resolves the value shape and constraints of a leaf field. */\nfunction buildScalar(field: FieldLike, base: PlanBase, registry: Map<string, FieldTypeDefinition>): ScalarPlan {\n const scope = resolveScope(field, registry);\n const options = usableOptions(field);\n const constraints = collectConstraints(field);\n\n if (options && (scope === 'single-select' || scope === 'multi-select')) {\n const types = [inferTypeFromValues(options.map((option) => option.value))] as JsonSchemaScalarType[];\n const multiple = scope === 'multi-select';\n return {\n ...base,\n kind: 'scalar',\n types: multiple || !field.nullable ? types : [...types, 'null'],\n options,\n multiple,\n default: jsonPrimitive(field.value),\n constraints,\n };\n }\n\n if (scope === 'date') {\n return {\n ...base,\n kind: 'scalar',\n types: withNull(['string'], field.nullable),\n multiple: false,\n default: jsonPrimitive(field.value),\n constraints: { ...constraints, format: constraints.format ?? 'date' },\n };\n }\n\n return {\n ...base,\n kind: 'scalar',\n types: withNull([scalarTypeFor(scope, field)], field.nullable),\n multiple: false,\n default: jsonPrimitive(field.value),\n constraints,\n };\n}\n\n/**\n * Selectable option values, with the label that explains what each one means.\n *\n * Disabled options are dropped: they are not selectable, so offering them to an\n * agent only invites a rejected write. A label that is an Observable or Signal\n * resolves at render time and is left off rather than stringified.\n */\nfunction usableOptions(field: FieldLike): readonly OptionPlan[] | undefined {\n if (!field.options?.length) return undefined;\n\n const usable = field.options\n .filter((option) => option.disabled !== true)\n .map((option) => {\n const title = staticText(option.label);\n return title ? { value: option.value, title } : { value: option.value };\n });\n\n return usable.length ? usable : undefined;\n}\n\n/** Maps a resolved scope to a JSON Schema scalar type. */\nfunction scalarTypeFor(scope: FieldScope | undefined, field: FieldLike): JsonSchemaScalarType {\n switch (scope) {\n case 'boolean':\n return 'boolean';\n case 'numeric':\n return 'number';\n case 'text-input':\n return 'string';\n default:\n // Unregistered or scope-less field type: fall back to the declared default\n // value's runtime type, then to string.\n return inferTypeFromValues([field.value]);\n }\n}\n\n/**\n * Resolves a field's scope, disambiguating the `['text-input', 'numeric']` pair\n * that text inputs declare (see `material-field-config.ts`) via `props.type`,\n * matching how `getFieldDefaultValue` decides between `''` and `NaN`.\n */\nfunction resolveScope(field: FieldLike, registry: Map<string, FieldTypeDefinition>): FieldScope | undefined {\n const declared = registry.get(field.type)?.scope;\n if (!declared) return undefined;\n\n if (!Array.isArray(declared)) return declared;\n\n if (declared.includes('numeric') && declared.includes('text-input')) {\n return field.props?.type === 'number' ? 'numeric' : 'text-input';\n }\n\n return declared[0];\n}\n\n/** Infers a scalar type from runtime values, defaulting to string. */\nfunction inferTypeFromValues(values: readonly unknown[]): JsonSchemaScalarType {\n const known = values.filter((value) => value !== null && value !== undefined);\n if (!known.length) return 'string';\n if (known.every((value) => typeof value === 'number')) return 'number';\n if (known.every((value) => typeof value === 'boolean')) return 'boolean';\n return 'string';\n}\n\nfunction withNull(types: readonly JsonSchemaScalarType[], nullable: boolean | undefined): readonly JsonSchemaScalarType[] {\n return nullable ? [...types, 'null'] : types;\n}\n\n/**\n * Collects static constraints from both validation APIs.\n *\n * ng-forge's normal field API is the shorthand — `required`, `email`, `min`,\n * `max`, `minLength`, `maxLength`, `pattern` declared directly on the field —\n * with `validators[]` as the advanced escape hatch. Both are read here; the\n * shorthand is applied first so an explicit `validators` entry wins.\n *\n * Conditional (`when`) and expression-driven validators are skipped: they depend\n * on live form state, and baking them into a schema the agent may have cached\n * would misreport what the form currently accepts. `fill` reports those live.\n */\nfunction collectConstraints(field: FieldLike): PlanConstraints {\n const constraints: PlanConstraints = {};\n\n if (typeof field.minLength === 'number' && field.type !== 'array') constraints.minLength = field.minLength;\n if (typeof field.maxLength === 'number' && field.type !== 'array') constraints.maxLength = field.maxLength;\n if (typeof field.min === 'number') constraints.minimum = field.min;\n if (typeof field.max === 'number') constraints.maximum = field.max;\n if (field.pattern instanceof RegExp) constraints.pattern = field.pattern.source;\n else if (typeof field.pattern === 'string') constraints.pattern = field.pattern;\n if (field.email) constraints.format = 'email';\n\n for (const validator of (field as FieldWithValidators).validators ?? []) {\n if (!isStaticValidator(validator)) continue;\n const value = validator.value;\n\n switch (validator.type) {\n case 'minLength':\n if (typeof value === 'number') constraints.minLength = value;\n break;\n case 'maxLength':\n if (typeof value === 'number') constraints.maxLength = value;\n break;\n case 'min':\n if (typeof value === 'number') constraints.minimum = value;\n break;\n case 'max':\n if (typeof value === 'number') constraints.maximum = value;\n break;\n case 'pattern':\n if (value instanceof RegExp) constraints.pattern = value.source;\n else if (typeof value === 'string') constraints.pattern = value;\n break;\n case 'email':\n constraints.format = 'email';\n break;\n }\n }\n\n return constraints;\n}\n\n/** A validator contributes to the structural schema only when it always applies. */\nfunction isStaticValidator(validator: ValidatorLike): boolean {\n return validator.when === undefined && validator.expression === undefined;\n}\n\n/**\n * Resolves what an agent may do with a field.\n *\n * The defaults are the conservative reading of the field itself, so a form that\n * says nothing about agents still does not hand a password back or let one\n * overwrite a derived value. `webMcp` on the field overrides either axis.\n */\nfunction resolvePolicy(field: FieldLike): PlanPolicy {\n const declared = field.webMcp;\n if (declared === false) return { readable: false, writable: false };\n\n // A `hidden` field type carries a value the user never sees — usually an id or\n // a correlation token. Neither half of it is an agent's business by default.\n const isHiddenType = field.type === 'hidden';\n const isSecret = field.props?.type === 'password';\n const isDerived = field.derivation !== undefined || (field.logic ?? []).some((rule) => rule?.type === 'derivation');\n const isReadonly = field.readonly === true || (field.logic ?? []).some((rule) => rule?.type === 'readonly');\n\n return {\n readable: declared?.readable ?? (!isHiddenType && !isSecret),\n writable: declared?.writable ?? (!isHiddenType && !isDerived && !isReadonly),\n };\n}\n\n/**\n * `DynamicText` may be an Observable or Signal. Only a literal string is a\n * structural fact, so anything else is dropped rather than stringified.\n */\nfunction staticText(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\nfunction jsonPrimitive(value: unknown): string | number | boolean | null | undefined {\n if (value === null) return null;\n if (typeof value === 'string' || typeof value === 'boolean') return value;\n if (typeof value === 'number' && Number.isFinite(value)) return value;\n return undefined;\n}\n\n/** Narrows an unknown `fields` property to a field array. */\nfunction asFieldArray(value: unknown): readonly FieldDef<unknown>[] | undefined {\n return Array.isArray(value) ? (value as readonly FieldDef<unknown>[]) : undefined;\n}\n","import type { ValidationError } from '@angular/forms/signals';\nimport { interpolateParams, ValidationMessages } from '@ng-forge/dynamic-forms/internal';\nimport type { SubmissionOutcome } from '../../utils/submission-handler/submission-outcome';\nimport type { FieldReport } from './collect-field-reports';\n\n/** A single validation failure, phrased for an agent to act on. */\nexport interface ErrorReport {\n path: string;\n message: string;\n}\n\n/** Everything a tool call reports back. */\nexport interface FormReport {\n /** The values the agent is allowed to see, already scoped and redacted. */\n values: unknown;\n fields: FieldReport[];\n errors: ErrorReport[];\n /** Paths this call wrote, in the order they were given. */\n changed: readonly string[];\n /** True when async validators had not finished, so the error list is provisional. */\n validationPending: boolean;\n /** Whether `values` is the whole readable model or only what this call changed. */\n scope: 'changed' | 'all';\n}\n\n/**\n * Turns Signal Forms validation errors into agent-readable rows.\n *\n * Resolves each error against the messages the author already wrote for humans:\n * the field's own `validationMessages` first, then the form's\n * `defaultValidationMessages`, then whatever the error itself carries, and\n * finally the bare `kind` so an unlabelled custom error still says something.\n *\n * Only literal string messages are used. A `DynamicText` message (Observable or\n * Signal) resolves asynchronously per field at render time, which is not\n * available here; falling back to the kind matches how the plan builder handles\n * dynamic labels.\n *\n * @internal\n */\nexport function toErrorReports(\n errors: readonly ValidationError.WithFieldTree[],\n paths: Map<unknown, string>,\n messages: Map<unknown, ValidationMessages>,\n defaults: ValidationMessages | undefined,\n): ErrorReport[] {\n return errors.map((error) => {\n const kind = String(error.kind);\n const fieldMessages = messages.get(error.fieldTree);\n const template =\n staticMessage(fieldMessages?.[kind as keyof ValidationMessages]) ?? staticMessage(defaults?.[kind as keyof ValidationMessages]);\n\n return {\n path: paths.get(error.fieldTree) ?? '',\n message: template ? interpolateParams(template, error) : (error.message ?? kind),\n };\n });\n}\n\n/** Narrows a `ValidationMessage` to a literal string, ignoring dynamic forms of it. */\nfunction staticMessage(message: unknown): string | undefined {\n return typeof message === 'string' && message.length > 0 ? message : undefined;\n}\n\n/**\n * Renders the response to arguments that never reached the form.\n *\n * Says plainly that nothing changed, because the parser rejects a bad call whole\n * rather than applying the half of it that parsed. An agent that has to guess\n * which fields landed will re-send everything, or worse, only the rest.\n *\n * @internal\n */\nexport function renderRejection(errors: readonly string[]): string {\n return ['Nothing was applied. The form is unchanged.', '', 'Problems with the arguments:', ...errors.map((error) => `- ${error}`)].join(\n '\\n',\n );\n}\n\n/**\n * Renders a report as the text an agent receives.\n *\n * Plain prose rather than raw JSON: agents act on this directly, and a\n * `path: message` list is both shorter and less ambiguous than a nested object\n * for the failure case that matters most.\n *\n * @internal\n */\nexport function renderFormReport(report: FormReport): string {\n const lines: string[] = [];\n\n if (report.changed.length) {\n lines.push(`Applied: ${report.changed.join(', ')}.`);\n lines.push(report.scope === 'all' ? 'Current values:' : 'Current values of the fields you set:');\n lines.push(JSON.stringify(report.values, null, 2));\n } else {\n lines.push('No changes made.');\n if (report.scope === 'all') {\n lines.push('Current values:');\n lines.push(JSON.stringify(report.values, null, 2));\n }\n }\n\n appendFieldSections(lines, report.fields);\n appendErrorSection(lines, report.errors, report.validationPending);\n\n return lines.join('\\n');\n}\n\nfunction appendFieldSections(lines: string[], fields: readonly FieldReport[]): void {\n const inapplicable = fields.filter((field) => !field.applicable);\n if (inapplicable.length) {\n lines.push('');\n lines.push(`Not currently applicable (do not send these): ${paths(inapplicable)}`);\n }\n\n // Qualified rather than bare, because this is the set of fields the form's\n // rules currently demand, not the set that is still missing. Unqualified, it\n // reads as an unmet-state list like \"Still empty\" below it, and does not\n // change when a field is filled, which looks like a write that did not land.\n const required = fields.filter((field) => field.applicable && field.required);\n if (required.length) {\n lines.push('');\n lines.push(`Required (given the current values): ${paths(required)}`);\n }\n\n // The orientation half of the report. Naming the blanks is what a whole-model\n // dump was really being used for, and it says the same thing without handing\n // back values the agent never asked for.\n const empty = fields.filter((field) => field.applicable && field.writable && !field.filled);\n if (empty.length) {\n lines.push('');\n lines.push(`Still empty: ${paths(empty)}`);\n }\n\n const locked = fields.filter((field) => field.applicable && !field.writable);\n if (locked.length) {\n lines.push('');\n lines.push(`Cannot be set by an agent: ${paths(locked)}`);\n }\n}\n\nfunction appendErrorSection(lines: string[], errors: readonly ErrorReport[], validationPending: boolean): void {\n if (errors.length) {\n lines.push('');\n lines.push('Validation errors:');\n for (const error of errors) {\n lines.push(`- ${error.path ? `${error.path}: ` : ''}${error.message}`);\n }\n }\n\n // Never report \"no validation errors\" while validators are still running: that\n // is a claim about a result nobody has yet.\n if (validationPending) {\n lines.push('');\n lines.push('Validation has not finished — some checks are still running, so this list may be incomplete. Call again to re-read it.');\n return;\n }\n\n if (!errors.length) {\n lines.push('');\n lines.push('No validation errors.');\n }\n}\n\nfunction paths(fields: readonly FieldReport[]): string {\n return fields.map((field) => field.path).join(', ');\n}\n\n/**\n * Renders the result of a submit attempt.\n *\n * Every branch here is a real thing the submission pipeline does. Reporting\n * \"submitted successfully\" the moment the event is dispatched was wrong for four\n * of them: an action that rejects later, a submission skipped because async\n * validation had not resolved, one dropped because another was already running,\n * and server-side validation errors that arrive after the call.\n *\n * @internal\n */\nexport function renderSubmitResult(outcome: SubmissionOutcome, report: FormReport): string {\n switch (outcome.status) {\n case 'success':\n return 'Form submitted successfully.';\n\n case 'dispatched':\n return 'Form submitted. The page handled the submission itself, so there is no result to report back.';\n\n case 'server-errors':\n return renderFailure('Submitted, but it came back with errors.', report, 'Correct these and call again.');\n\n case 'action-failed':\n return renderFailure(\n `Not submitted: the submission failed. ${describeError(outcome.error)}`,\n report,\n 'The values you sent are still in the form. This may be worth retrying.',\n );\n\n case 'validation-failed':\n return renderFailure('Not submitted: validation failed.', report, changedNote(report));\n\n case 'pending-validation':\n return renderFailure(\n 'Not submitted: validation had not finished when the form was asked to submit.',\n report,\n 'The values you sent are still in the form. Call again in a moment.',\n );\n\n case 'busy':\n return 'Not submitted: this form is already submitting. Wait for that to finish before trying again.';\n\n case 'cancelled':\n return 'Not submitted: the form went away before the submission finished.';\n }\n}\n\nfunction renderFailure(headline: string, report: FormReport, closing: string): string {\n const lines = [headline];\n\n for (const error of report.errors) {\n lines.push(`- ${error.path ? `${error.path}: ` : ''}${error.message}`);\n }\n\n lines.push('');\n lines.push(closing);\n return lines.join('\\n');\n}\n\nfunction changedNote(report: FormReport): string {\n return report.changed.length\n ? 'The values you sent were applied to the form and are still there. Send only the corrected fields and call again.'\n : 'The form was not modified. Send the corrected fields and call again.';\n}\n\n/** Reports that an action failed without echoing an unbounded error payload. */\nfunction describeError(error: unknown): string {\n const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';\n return message ? `Reason: ${message.slice(0, 200)}` : 'No reason was given.';\n}\n","import type { JsonSchemaObject } from './json-schema';\n\n/**\n * What the browser hands `execute` alongside the arguments.\n *\n * The draft passes a per-invocation `AbortSignal` here and asks implementations\n * to carry it into whatever asynchronous work the call starts. It is separate\n * from the registration signal on purpose: unregistering a tool does not cancel\n * an execution already in flight, so an epoch abort cannot stand in for this.\n *\n * Chrome 150 passes nothing at all. Logging what a real `execute` receives\n * shows a single argument, so `context` is `undefined` there and the\n * cancellation it would carry never arrives. Everything downstream treats a\n * missing signal as \"no per-invocation cancellation\" rather than an error, so\n * the form still settles its submissions through the registration signal, which\n * a real browser does honour.\n *\n * Hence every member optional: this is read off an object the platform owns,\n * the shape is still moving, and the absent case is the current one.\n */\nexport interface ToolExecutionContext {\n signal?: AbortSignal;\n}\n\n/** A tool descriptor, as the browser's model context expects it. */\nexport interface ToolDescriptor {\n name: string;\n description: string;\n inputSchema: JsonSchemaObject;\n annotations?: Record<string, unknown>;\n execute: (args: Record<string, unknown>, context?: ToolExecutionContext) => Promise<string>;\n}\n\n/** The slice of the browser's model context this library uses. */\ninterface ModelContextLike {\n registerTool(descriptor: ToolDescriptor, options?: { signal?: AbortSignal }): unknown;\n}\n\n/**\n * Tool names the draft accepts: 1–128 characters, ASCII alphanumerics plus\n * `_`, `-` and `.`. Anything else is rejected by `registerTool()`, and a\n * rejection that nobody awaits looks exactly like success.\n *\n * @see https://webmachinelearning.github.io/webmcp/\n */\nconst NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;\n\n/**\n * Chrome's guidance is to keep tool names inside a small budget so an agent\n * choosing between many tools can read them. Not a hard limit — a longer name\n * still registers — so this only warns.\n *\n * @see https://developer.chrome.com/docs/ai/webmcp/secure-tools\n */\nconst NAME_BUDGET = 30;\n\n/**\n * Locates the browser's model context.\n *\n * `document.modelContext` is the current surface; `navigator.modelContext` is\n * deprecated as of Chrome 150 and kept as a fallback for the origin trial's\n * earlier shape. Returns `undefined` where WebMCP is unavailable — an\n * unsupported browser, a document without the `tools` Permissions Policy, or a\n * non-isolated origin — which is a normal outcome, not an error.\n *\n * @internal\n */\nexport function findModelContext(): ModelContextLike | undefined {\n // `modelContext` is not in the DOM lib yet, so both hosts are read through an\n // index signature rather than a declaration merge that would leak globally.\n const hosts = globalThis as unknown as { document?: Record<string, unknown>; navigator?: Record<string, unknown> };\n\n const fromDocument = hosts.document?.['modelContext'];\n if (isModelContext(fromDocument)) return fromDocument;\n\n const fromNavigator = hosts.navigator?.['modelContext'];\n if (isModelContext(fromNavigator)) return fromNavigator;\n\n return undefined;\n}\n\nfunction isModelContext(candidate: unknown): candidate is ModelContextLike {\n return typeof (candidate as ModelContextLike | undefined)?.registerTool === 'function';\n}\n\n/** Why a tool name was refused, or `undefined` when it is usable. */\nexport function validateToolName(name: string): string | undefined {\n if (!NAME_PATTERN.test(name)) {\n return `Tool name \"${name}\" is not usable: names must be 1-128 characters of A-Z, a-z, 0-9, \"_\", \"-\" or \".\".`;\n }\n return undefined;\n}\n\n/** Whether a usable name is longer than the budget agents can comfortably scan. */\nexport function isOverNameBudget(name: string): boolean {\n return name.length > NAME_BUDGET;\n}\n\n/**\n * Registers one tool and reports whether it actually landed.\n *\n * The draft's `registerTool()` is asynchronous and rejects on a duplicate name,\n * an invalid name, a permissions failure, or a descriptor it cannot serialize.\n * Angular's `declareExperimentalWebMcpTool()` calls it without returning or\n * awaiting the promise, so every one of those failures currently surfaces as\n * silence — which is why this talks to the platform directly instead.\n *\n * @internal\n */\nexport async function registerTool(\n context: ModelContextLike,\n descriptor: ToolDescriptor,\n signal: AbortSignal,\n): Promise<{ ok: true } | { ok: false; reason: string }> {\n const invalid = validateToolName(descriptor.name);\n if (invalid) return { ok: false, reason: invalid };\n\n if (signal.aborted) return { ok: false, reason: 'Registration was superseded before it started.' };\n\n try {\n await context.registerTool(descriptor, { signal });\n return { ok: true };\n } catch (error: unknown) {\n return { ok: false, reason: error instanceof Error ? error.message : String(error) };\n }\n}\n","import type { ItemPlan, PlanNode, ScalarPlan } from './field-plan';\nimport type { JsonSchemaScalarType } from './json-schema';\n\n/** The outcome of checking an agent's arguments against the field plan. */\nexport type ParseResult = { ok: true; patch: Record<string, unknown>; paths: readonly string[] } | { ok: false; errors: readonly string[] };\n\n/**\n * Reports why a field cannot be written *right now*, or `undefined` when it can.\n * Live state the config cannot know about — a field the form has disabled or\n * made readonly through a logic rule.\n */\nexport type LiveWriteBlock = (path: string) => string | undefined;\n\n/** Sentinel for \"this key failed to parse\", distinct from a legitimate `undefined`. */\nconst SKIP = Symbol('skip');\n\n/**\n * Validates an agent's arguments against the field plan before anything is written.\n *\n * `inputSchema` is not a gate. Angular's own WebMCP documentation warns that\n * agent input may not be validated against the schema at all, and Chrome's\n * guidance is to validate strictly in code and treat the schema as the looser,\n * advisory half. So this is the actual boundary: nothing reaches the form model\n * until the whole patch parses.\n *\n * Rejection is atomic on purpose. A patch that half-applies leaves the form in a\n * state neither the user nor the agent asked for, and the agent has no way to\n * find out which half landed. Every problem in the call is collected and\n * returned together so one round trip is enough to fix them all.\n *\n * What it enforces: known properties only, runtime types (including `null` only\n * where the field is nullable), enum membership, object and array structure, and\n * the writable half of each field's exposure policy. What it deliberately leaves\n * to the form: value constraints such as `minLength` or `pattern`, which the\n * form's own validators check and report with the message the author wrote.\n *\n * @internal\n */\nexport function parseAgentInput(plan: readonly PlanNode[], input: unknown, liveBlock?: LiveWriteBlock, current?: unknown): ParseResult {\n if (input === undefined || input === null) {\n return { ok: true, patch: {}, paths: [] };\n }\n\n if (!isPlainObject(input)) {\n return { ok: false, errors: ['Arguments must be an object of field names to values.'] };\n }\n\n const errors: string[] = [];\n const paths: string[] = [];\n const patch = parseLevel(plan, input, '', errors, paths, liveBlock, isPlainObject(current) ? current : undefined);\n\n return errors.length ? { ok: false, errors } : { ok: true, patch, paths };\n}\n\nfunction parseLevel(\n nodes: readonly PlanNode[],\n input: Record<string, unknown>,\n prefix: string,\n errors: string[],\n paths: string[],\n liveBlock: LiveWriteBlock | undefined,\n current: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n const byKey = new Map(nodes.map((node) => [node.key, node]));\n const patch: Record<string, unknown> = {};\n\n for (const [key, value] of Object.entries(input)) {\n const label = prefix ? `${prefix}.${key}` : key;\n const node = byKey.get(key);\n\n if (!node) {\n const writable = nodes.filter((candidate) => candidate.policy.writable).map((candidate) => candidate.key);\n errors.push(`Unknown field \"${label}\". This form accepts: ${writable.join(', ') || '(no writable fields)'}.`);\n continue;\n }\n\n if (!node.policy.writable) {\n errors.push(`Field \"${label}\" cannot be set by an agent.`);\n continue;\n }\n\n const blocked = liveBlock?.(node.path);\n if (blocked) {\n errors.push(`Field \"${label}\" cannot be set right now: ${blocked}.`);\n continue;\n }\n\n const parsed = parseNode(node, value, label, errors, paths, liveBlock, current?.[key]);\n if (parsed !== SKIP) {\n patch[key] = parsed;\n // A group contributes its leaves, not itself: reporting the group as\n // \"changed\" would scope the readback to the whole object, handing back\n // sibling values the call never touched. An array is one path, because it\n // is replaced whole.\n if (node.kind !== 'group') paths.push(node.path);\n }\n }\n\n return patch;\n}\n\nfunction parseNode(\n node: PlanNode,\n value: unknown,\n label: string,\n errors: string[],\n paths: string[],\n liveBlock: LiveWriteBlock | undefined,\n current: unknown,\n): unknown {\n switch (node.kind) {\n case 'scalar':\n return parseScalar(node, value, label, errors);\n\n case 'group': {\n if (!isPlainObject(value)) {\n errors.push(`Field \"${label}\" is a group and expects an object.`);\n return SKIP;\n }\n const before = errors.length;\n const nested = parseLevel(node.children, value, label, errors, paths, liveBlock, isPlainObject(current) ? current : undefined);\n return errors.length === before ? nested : SKIP;\n }\n\n case 'array': {\n if (!Array.isArray(value)) {\n errors.push(`Field \"${label}\" is a list and expects an array.`);\n return SKIP;\n }\n if (!node.item) {\n errors.push(`Field \"${label}\" has an item shape that cannot be described to an agent, so it cannot be set.`);\n return SKIP;\n }\n const protectedKeys = protectedValuesInUse(node.item, current);\n if (protectedKeys.length) {\n errors.push(\n `Field \"${label}\" cannot be replaced: its items carry ${protectedKeys.join(', ')}, which an agent cannot set, ` +\n `and a list is replaced whole rather than merged item by item. Sending it would discard those values.`,\n );\n return SKIP;\n }\n\n const before = errors.length;\n // Item paths are template paths (`lines[].sku`), not something an agent\n // could send back, so they are collected and discarded.\n const itemPaths: string[] = [];\n const items = value.map((item, index) => parseItem(node.item as ItemPlan, item, `${label}[${index}]`, errors, itemPaths, liveBlock));\n return errors.length === before ? items : SKIP;\n }\n }\n}\n\nfunction parseItem(\n item: ItemPlan,\n value: unknown,\n label: string,\n errors: string[],\n paths: string[],\n liveBlock: LiveWriteBlock | undefined,\n): unknown {\n if (item.kind === 'value') {\n return parseScalar(item.value, value, label, errors);\n }\n\n if (!isPlainObject(value)) {\n errors.push(`Item \"${label}\" expects an object.`);\n return SKIP;\n }\n\n // Items are replaced whole, so an item is parsed against its full template\n // rather than merged into whatever sat at that index before. There is no\n // \"current item\" to carry forward: `protectedValuesInUse` has already refused\n // any list whose items hold values the agent could not resend.\n return parseLevel(item.children, value, label, errors, paths, liveBlock, undefined);\n}\n\nfunction parseScalar(plan: ScalarPlan, value: unknown, label: string, errors: string[]): unknown {\n if (plan.multiple) {\n if (!Array.isArray(value)) {\n errors.push(`Field \"${label}\" accepts multiple values and expects an array.`);\n return SKIP;\n }\n const invalid = value.filter((entry) => !isAllowedOption(plan, entry));\n if (invalid.length) {\n errors.push(`Field \"${label}\" got ${describe(invalid[0])}, which is not one of: ${optionList(plan)}.`);\n return SKIP;\n }\n return value;\n }\n\n if (plan.options) {\n if (!isAllowedOption(plan, value) && !(value === null && plan.types.includes('null'))) {\n errors.push(`Field \"${label}\" got ${describe(value)}, which is not one of: ${optionList(plan)}.`);\n return SKIP;\n }\n return value;\n }\n\n if (!plan.types.some((type) => matchesType(type, value))) {\n errors.push(`Field \"${label}\" expects ${plan.types.join(' or ')} but got ${describe(value)}.`);\n return SKIP;\n }\n\n return value;\n}\n\nfunction isAllowedOption(plan: ScalarPlan, value: unknown): boolean {\n return (plan.options ?? []).some((option) => Object.is(option.value, value));\n}\n\nfunction optionList(plan: ScalarPlan): string {\n return (plan.options ?? []).map((option) => JSON.stringify(option.value)).join(', ');\n}\n\nfunction matchesType(type: JsonSchemaScalarType, value: unknown): boolean {\n switch (type) {\n case 'string':\n return typeof value === 'string';\n case 'number':\n return typeof value === 'number' && Number.isFinite(value);\n case 'integer':\n return typeof value === 'number' && Number.isInteger(value);\n case 'boolean':\n return typeof value === 'boolean';\n case 'null':\n return value === null;\n }\n}\n\n/** Describes a rejected value without echoing a long payload back to the agent. */\nfunction describe(value: unknown): string {\n if (value === null) return 'null';\n if (value === undefined) return 'undefined';\n if (Array.isArray(value)) return 'an array';\n if (typeof value === 'object') return 'an object';\n if (typeof value === 'string') return value.length > 40 ? 'a long string' : JSON.stringify(value);\n return JSON.stringify(value);\n}\n\n/**\n * Names the non-writable item fields that currently hold a value, so replacing\n * the list would silently drop them.\n *\n * A list is replaced whole, and the agent only ever sees its items' writable\n * fields. So an item holding anything else — a server-assigned id, a hidden\n * correlation token, a price the application controls — cannot survive a\n * rewrite: the agent has no way to send those values back, and nothing else\n * could put them back afterwards.\n *\n * Carrying them forward by position instead is worse, not better. The agent is\n * sending the list it wants, reordering included, and index 2 of the new list is\n * not index 2 of the old one. Pinning an id to a position quietly attaches it to\n * a different item, which is corruption where a rejection is merely a refusal.\n *\n * So the write is refused while any protected value is actually at stake. An\n * empty list, or one whose protected fields are all still unset, has nothing to\n * lose and is written normally — which keeps the common \"add the first few\n * items\" case working.\n */\nfunction protectedValuesInUse(item: ItemPlan, current: unknown): readonly string[] {\n if (item.kind !== 'object' || !Array.isArray(current) || !current.length) return [];\n\n const names = new Set<string>();\n\n for (const existing of current) {\n collectProtected(item.children, existing, '', names);\n }\n\n return [...names];\n}\n\nfunction collectProtected(nodes: readonly PlanNode[], value: unknown, prefix: string, into: Set<string>): void {\n if (!isPlainObject(value)) return;\n\n for (const node of nodes) {\n const name = prefix ? `${prefix}.${node.key}` : node.key;\n const held = value[node.key];\n\n if (node.kind === 'group') {\n collectProtected(node.children, held, name, into);\n continue;\n }\n\n // A field the agent may write can be resent, so it is never at stake.\n if (!node.policy.writable && holdsValue(held)) {\n into.add(`\"${name}\"`);\n continue;\n }\n\n // A list the agent *may* write is not itself at stake, but its own items can\n // still hold values it cannot resend, and replacing the outer list replaces\n // those too. The loss is the same at any depth, so the walk follows it down.\n if (node.kind === 'array' && node.item && Array.isArray(held)) {\n collectProtectedInItems(node.item, held, `${name}[]`, into);\n }\n }\n}\n\nfunction collectProtectedInItems(item: ItemPlan, items: readonly unknown[], prefix: string, into: Set<string>): void {\n for (const entry of items) {\n if (item.kind === 'value') {\n if (!item.value.policy.writable && holdsValue(entry)) into.add(`\"${prefix}\"`);\n continue;\n }\n\n collectProtected(item.children, entry, prefix, into);\n }\n}\n\n/**\n * Whether a field currently holds something a rewrite would destroy.\n *\n * `null` counts as unset because it is the default ng-forge gives a nullable\n * field nobody filled in, and an empty list counts as unset for the same\n * reason: there is nothing in it to lose.\n */\nfunction holdsValue(value: unknown): boolean {\n if (value === undefined || value === null || value === '') return false;\n if (Array.isArray(value)) return value.length > 0;\n return true;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n","import type { PlanNode } from './field-plan';\n\n/**\n * Merges a validated patch into the form model, following the field plan.\n *\n * A top-level spread is not a partial update. `{ person: { first: 'Grace' } }`\n * spread over `{ person: { first: 'Ada', last: 'Lovelace' } }` replaces the\n * whole `person` object and silently deletes `last`, which is data loss rather\n * than an incomplete report. Groups are therefore merged key by key, all the way\n * down.\n *\n * Arrays are replaced whole. There is no positional patch an agent could express\n * unambiguously — index 1 of a five-item list means nothing once the list is\n * reordered — so sending a list means sending the list it should become.\n *\n * @internal\n */\nexport function mergePatch(\n current: Record<string, unknown>,\n patch: Record<string, unknown>,\n plan: readonly PlanNode[],\n): Record<string, unknown> {\n const byKey = new Map(plan.map((node) => [node.key, node]));\n const next: Record<string, unknown> = { ...current };\n\n for (const [key, value] of Object.entries(patch)) {\n const node = byKey.get(key);\n\n if (node?.kind === 'group' && isPlainObject(value)) {\n const existing = isPlainObject(current[key]) ? (current[key] as Record<string, unknown>) : {};\n next[key] = mergePatch(existing, value, node.children);\n continue;\n }\n\n next[key] = value;\n }\n\n return next;\n}\n\n/**\n * Strips values the agent is not allowed to read, keeping the structure intact.\n *\n * A redacted field is not deleted: the agent still needs to know the field\n * exists and, from the field report, whether it currently holds a value. Only\n * the value itself is withheld, replaced by a marker that says why.\n *\n * @internal\n */\nexport const REDACTED = '[not readable by agents]';\n\n/** @internal */\nexport function redactValues(value: unknown, plan: readonly PlanNode[]): unknown {\n if (!isPlainObject(value)) return value;\n\n const out: Record<string, unknown> = {};\n\n for (const node of plan) {\n if (!(node.key in value)) continue;\n\n if (!node.policy.readable) {\n out[node.key] = REDACTED;\n continue;\n }\n\n out[node.key] = redactNode(node, value[node.key]);\n }\n\n return out;\n}\n\nfunction redactNode(node: PlanNode, value: unknown): unknown {\n switch (node.kind) {\n case 'group':\n return redactValues(value, node.children);\n case 'array':\n if (!Array.isArray(value) || node.item?.kind !== 'object') return value;\n return value.map((item) => redactValues(item, (node.item as { children: readonly PlanNode[] }).children));\n case 'scalar':\n return value;\n }\n}\n\n/**\n * Narrows a form value down to the paths a call actually wrote.\n *\n * The default readback for `fill` and `submit`. Returning the whole model on\n * every call — including a call with no arguments — hands an agent every\n * prefilled value on the page: identifiers, personal data entered by the user\n * before the agent arrived, anything the form happens to hold. Chrome's own\n * guidance is that even a read-only tool can reveal user information, so the\n * default is to echo back what the call changed and nothing more. A form that\n * wants the full picture opts in with `webMcp.readback: 'all'`.\n *\n * @internal\n */\nexport function pickPaths(value: unknown, paths: readonly string[]): unknown {\n if (!isPlainObject(value)) return {};\n\n const out: Record<string, unknown> = {};\n\n for (const path of paths) {\n const [head, ...rest] = path.split('.');\n if (!(head in value)) continue;\n\n if (!rest.length) {\n out[head] = value[head];\n continue;\n }\n\n const nested = pickPaths(value[head], [rest.join('.')]);\n out[head] = isPlainObject(out[head]) ? { ...(out[head] as Record<string, unknown>), ...(nested as object) } : nested;\n }\n\n return out;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n","import { inject, untracked } from '@angular/core';\nimport type { FieldTree } from '@angular/forms/signals';\nimport { DynamicFormLogger, EventBus, WebMcpToolOptions } from '@ng-forge/dynamic-forms/internal';\nimport { FormStateManager } from '../../state/form-state-manager';\nimport { FormSubmitEvent } from '../../events/constants/submit.event';\nimport { createPendingSubmission, SubmissionOutcome } from '../../utils/submission-handler/submission-outcome';\nimport { buildToolSchema } from './build-tool-schema';\nimport { collectFieldReports, FieldWalk } from './collect-field-reports';\nimport { buildFieldPlan } from './field-plan';\nimport { FormReport, renderFormReport, renderRejection, renderSubmitResult, toErrorReports } from './format-report';\nimport { findModelContext, isOverNameBudget, registerTool, ToolDescriptor, ToolExecutionContext } from './model-context';\nimport { parseAgentInput } from './parse-agent-input';\nimport { mergePatch, pickPaths, redactValues } from './patch-values';\nimport type { WebMcpStatus } from './web-mcp-gate';\n\n/** Text returned when the agent's arguments could not be applied at all. */\nconst NOT_READY = 'The form is not ready yet. Try again shortly.';\n\n/**\n * Text returned when the call was already over before it began.\n *\n * Said plainly because the agent has to be able to tell this apart from a\n * refusal it could fix: nothing was read, nothing was written, and retrying is a\n * reasonable next move.\n */\nconst CANCELLED = 'The call was cancelled before it ran, so the form is unchanged and nothing was submitted.';\n\n/**\n * How long to wait for async validators before reporting on a form anyway.\n *\n * A tool call has to answer eventually, and an agent left holding an open call\n * cannot do anything useful. On expiry the report says validation is still\n * running rather than claiming the form is clean.\n */\nconst SETTLE_TIMEOUT_MS = 5_000;\n\n/** Gap between checks of the form's pending-validators signal. */\nconst SETTLE_POLL_MS = 10;\n\n/**\n * Registers this form's WebMCP tools for one registration epoch.\n *\n * Called from `web-mcp-gate.ts` inside the form's injection context, once per\n * epoch — a fresh epoch on every change to the effective options or the field\n * setup. `signal` is that epoch's `AbortController`; aborting it unregisters\n * everything registered here, which is how a config swap or a revoked\n * `allowSubmit` actually takes effect.\n *\n * Lives in its own module so the dynamic `import()` in the gate has a chunk\n * boundary to split on — forms that never opt in do not load this code.\n *\n * Deliberately does not use `provideExperimentalWebMcpForms()`. That path builds\n * its schema by walking runtime values (which throws on the `null` and\n * empty-array defaults ng-forge produces) and submits with `submit(formTree)`,\n * which throws because ng-forge routes submission through its `EventBus` instead\n * of `FormOptions.submission`. It does not use `declareExperimentalWebMcpTool()`\n * either: that helper calls `registerTool()` without awaiting the promise, so a\n * duplicate name, an invalid name or a permissions failure all look like success.\n *\n * @returns The status the gate should report for this epoch.\n *\n * @internal\n */\nexport async function bootstrapWebMcp(options: WebMcpToolOptions, signal: AbortSignal): Promise<WebMcpStatus> {\n const stateManager = inject(FormStateManager);\n const eventBus = inject(EventBus);\n const logger = inject(DynamicFormLogger);\n\n const context = findModelContext();\n if (!context) {\n logger.debug('No WebMCP model context on this page; the form is not exposed to agents.');\n return 'unsupported';\n }\n\n const defaultMessages = untracked(() => stateManager.activeConfig()?.defaultValidationMessages);\n\n const setup = untracked(() => stateManager.formSetup());\n const plan = buildFieldPlan(setup?.schemaFields ?? [], setup?.registry ?? new Map(), (message) => logger.warn(message));\n const inputSchema = buildToolSchema(plan);\n\n const scope = options.readback === 'all' ? 'all' : 'changed';\n\n // Both tools echo form values back to the agent. Those values are user content\n // and can carry injected instructions, so they are flagged untrusted per\n // https://developer.chrome.com/docs/agents/security.\n const annotations = { untrustedContentHint: true };\n\n const descriptors: ToolDescriptor[] = [\n {\n name: `fill_${options.name}`,\n description:\n `Fill the \"${options.name}\" form: ${options.description} ` +\n `Accepts any subset of fields and leaves the rest untouched; a nested group is merged key by key, a list is replaced whole. ` +\n `Does not submit. Returns the values it set, which fields currently apply, which are still empty, and any validation errors. ` +\n `Call with no fields to see which apply, which are required and which are still empty, without changing anything.`,\n inputSchema,\n annotations,\n execute: (args, execution) => fill(args, invocationSignal(execution)),\n },\n ];\n\n // Submission is opt-in per form. Guidance is to avoid agent-triggered submits\n // for consequential actions unless the app has asked for it, and the platform's\n // own declarative forms API takes the same posture (manual submit by default,\n // `toolautosubmit` to opt in). Without this flag the agent can stage values and\n // a human presses the button.\n if (options.allowSubmit) {\n // Without a `submission.action` the page takes over at dispatch and the tool\n // cannot see what happened next. That is worth saying in the description\n // rather than only in the response: an agent that learns it after submitting\n // has already acted, and can only report the uncertainty after the fact.\n const reportsOutcome = untracked(() => stateManager.activeConfig()?.submission?.action) !== undefined;\n\n descriptors.push({\n name: `submit_${options.name}`,\n description:\n `Submit the \"${options.name}\" form: ${options.description} ` +\n `Applies any fields given, then submits and waits for the result. If validation fails nothing is submitted and the errors are ` +\n `returned; the values still remain in the form for correction. ` +\n (reportsOutcome\n ? `Reports whether the submission succeeded, was rejected by the server, or failed.`\n : `This page handles submission itself, so a successful call confirms the form was submitted but cannot report what came of it.`),\n inputSchema,\n // Submitting is the consequential half of this pair: it can place an\n // order, send a message or create an account.\n //\n // Chrome 150 drops this. Registering a tool with every hint the draft\n // mentions and reading the descriptor back shows the browser keeps\n // `readOnly` and `untrustedContent` and nothing else, so this asks for a\n // confirmation prompt that no shipping browser gives yet. It stays\n // because it costs one key and becomes real the day Chrome honours it;\n // what it must not do is stand in for `allowSubmit`, which is the gate\n // that actually decides whether this tool exists at all.\n annotations: { ...annotations, consequentialHint: true },\n execute: (args, execution) => submitForm(args, invocationSignal(execution)),\n });\n }\n\n const failures: string[] = [];\n\n for (const descriptor of descriptors) {\n if (isOverNameBudget(descriptor.name)) {\n logger.warn(\n `WebMCP tool name \"${descriptor.name}\" is longer than the ~30 characters agents can comfortably scan. ` +\n `A shorter \\`webMcp.name\\` reads better in a tool list.`,\n );\n }\n\n const result = await registerTool(context, descriptor, signal);\n if (!result.ok) failures.push(`${descriptor.name}: ${result.reason}`);\n }\n\n if (signal.aborted) return 'idle';\n\n if (failures.length) {\n logger.error(`WebMCP tool registration failed. ${failures.join(' ')}`);\n return 'failed';\n }\n\n return 'active';\n\n /**\n * Applies a validated patch to the live form and reports the result.\n *\n * Writing to the live form rather than a copy is deliberate: conditional logic\n * and cross-field validators resolve through an evaluation context bound to the\n * root registry, so only the live form can evaluate them correctly. It is also\n * what the user should see — an agent filling a form on their behalf is visible\n * work, not a side effect to hide.\n */\n async function fill(args: Record<string, unknown>, invocation: AbortSignal | undefined): Promise<string> {\n const tree = untracked(() => stateManager.form());\n if (!tree) return NOT_READY;\n\n const applied = await apply(args, tree, invocation);\n if ('rejection' in applied) return applied.rejection;\n\n return renderFormReport(applied.report);\n }\n\n /**\n * Applies values, then submits through the form's normal submission path and\n * waits for it to finish.\n *\n * The wait is the point. `EventBus.dispatch` returns the instant the event is\n * on the bus, long before an HTTP action resolves, so returning there reported\n * success for submissions that were about to fail, be skipped, or be dropped.\n */\n async function submitForm(args: Record<string, unknown>, invocation: AbortSignal | undefined): Promise<string> {\n const tree = untracked(() => stateManager.form());\n if (!tree) return NOT_READY;\n\n const applied = await apply(args, tree, invocation);\n if ('rejection' in applied) return applied.rejection;\n\n const report = applied.report;\n if (report.errors.length || report.validationPending) {\n return renderSubmitResult({ status: report.validationPending ? 'pending-validation' : 'validation-failed' }, report);\n }\n\n const outcome = await dispatchSubmit(invocation);\n\n // Re-read after the submission settled. Server errors are applied to the\n // fields by `submit()` once the action resolves, so the report taken before\n // dispatch would name the failure without saying what it was.\n return renderSubmitResult(outcome, reportNow(tree, report.changed));\n }\n\n /** Rebuilds the report from the form's current state. */\n function reportNow(tree: FieldTree<unknown>, changed: readonly string[]): FormReport {\n const walk = untracked(() => collectFieldReports(plan, tree));\n return buildReport(\n tree,\n walk,\n changed,\n untracked(() => tree().pending()),\n );\n }\n\n /**\n * Dispatches a submit and resolves with what the pipeline actually did.\n *\n * The reply channel is settled by whichever comes first: the submission\n * pipeline, the form going away, or the agent abandoning the call. Without\n * that last part the promise simply never settles — destroy the form mid-flight\n * and the agent is left holding an open tool call forever, with the form and\n * the submission it referenced pinned behind it. `cancel()` is a no-op once the\n * pipeline has already reported, so the real outcome always wins the race.\n */\n async function dispatchSubmit(invocation: AbortSignal | undefined): Promise<SubmissionOutcome> {\n const pending = createPendingSubmission();\n\n // An abort between applying and dispatching. A signal already aborted never\n // fires `abort` again, so it is checked rather than merely listened to.\n if (signal.aborted || invocation?.aborted) return { status: 'cancelled' };\n\n const cancel = (): void => pending.cancel();\n signal.addEventListener('abort', cancel, { once: true });\n invocation?.addEventListener('abort', cancel, { once: true });\n\n try {\n eventBus.dispatch(new FormSubmitEvent(pending.reply));\n\n // The bus is synchronous, so a reply still unclaimed here was dropped by\n // the pipeline's `exhaustMap` rather than merely not finished — the one way\n // to tell \"already submitting\" apart from \"still running\".\n if (!pending.accepted()) return { status: 'busy' };\n\n return await pending.outcome;\n } finally {\n signal.removeEventListener('abort', cancel);\n invocation?.removeEventListener('abort', cancel);\n }\n }\n\n /**\n * Validates, applies and reports in one place, since `fill` and `submit` need\n * exactly the same thing before they diverge.\n */\n async function apply(\n args: Record<string, unknown>,\n tree: FieldTree<unknown>,\n invocation: AbortSignal | undefined,\n ): Promise<{ rejection: string } | { report: FormReport }> {\n // Before anything is read or written. A call whose signal is already aborted\n // is one the agent has abandoned or the browser has withdrawn, and it is told\n // so; leaving its values behind anyway would be a change nobody asked for and\n // nobody is watching for, since the response says nothing happened.\n if (signal.aborted || invocation?.aborted) return { rejection: CANCELLED };\n\n // Live state first: a field the form has disabled or made readonly right now\n // is not writable, whatever the config said when the schema was built. The\n // current value goes in too, so the parser can see which lists hold values an\n // agent could not resend.\n const before = untracked(() => collectFieldReports(plan, tree));\n const parsed = parseAgentInput(\n plan,\n args,\n (path) => liveBlock(before, path),\n untracked(() => stateManager.formValue()),\n );\n\n if (!parsed.ok) return { rejection: renderRejection(parsed.errors) };\n\n const changed = parsed.paths;\n if (changed.length) {\n stateManager.entity.update((current) => mergePatch((current ?? {}) as Record<string, unknown>, parsed.patch, plan) as never);\n }\n\n const validationPending = changed.length ? await settle(tree, invocation) : untracked(() => tree().pending());\n const walk = untracked(() => collectFieldReports(plan, tree));\n\n return { report: buildReport(tree, walk, changed, validationPending) };\n }\n\n function buildReport(tree: FieldTree<unknown>, walk: FieldWalk, changed: readonly string[], validationPending: boolean): FormReport {\n const readable = redactValues(\n untracked(() => stateManager.formValue()),\n plan,\n );\n\n return {\n values: scope === 'all' ? readable : pickPaths(readable, changed),\n fields: walk.reports,\n errors: toErrorReports(\n untracked(() => tree().errorSummary()),\n walk.paths,\n walk.messages,\n defaultMessages,\n ),\n changed,\n validationPending,\n scope,\n };\n }\n\n /** Reports why a path cannot be written right now, per the form's live state. */\n function liveBlock(walk: FieldWalk, path: string): string | undefined {\n const state = walk.state.get(path);\n if (state?.disabled) return 'the form has disabled it';\n if (state?.readonly) return 'the form has made it read-only';\n return undefined;\n }\n\n /**\n * Waits for the form to finish reacting to what was just written.\n *\n * One resolved microtask was never a settled boundary: derivations run through\n * effects, and an async validator is by definition not done in the same tick.\n * This yields to the macrotask queue so Angular can flush, then watches the\n * form's own `pending()` signal until validators resolve.\n *\n * @returns Whether validation was *still* pending when the wait gave up.\n */\n async function settle(tree: FieldTree<unknown>, invocation: AbortSignal | undefined): Promise<boolean> {\n const deadline = Date.now() + SETTLE_TIMEOUT_MS;\n\n await nextTask();\n\n while (untracked(() => tree().pending())) {\n if (Date.now() >= deadline || signal.aborted || invocation?.aborted) return true;\n await nextTask(SETTLE_POLL_MS);\n }\n\n return false;\n }\n}\n\n/**\n * Reads the per-invocation `AbortSignal` off whatever the browser passed.\n *\n * Chrome 150 passes no second argument, so today this returns `undefined` every\n * time and the per-invocation half of cancellation is dormant; the registration\n * signal still settles a submission when the form goes away. The check is on the\n * value rather than on the shape because the context is platform-owned and still\n * moving, and guessing wrong should read as \"no signal\", never as a crash.\n */\nfunction invocationSignal(execution: ToolExecutionContext | undefined): AbortSignal | undefined {\n return execution?.signal instanceof AbortSignal ? execution.signal : undefined;\n}\n\nfunction nextTask(delayMs = 0): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, delayMs));\n}\n"]}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { inject, DestroyRef, Injector, EnvironmentInjector, viewChild, TemplateRef, ViewContainerRef, input, computed, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
-
import { i as injectFieldRegistry, c as createFieldResolutionPipe, r as resolveWrappers,
|
|
4
|
-
import { EventBus, DynamicFormLogger, WRAPPER_AUTO_ASSOCIATIONS, DEFAULT_WRAPPERS, derivedFromDeferred } from '@ng-forge/dynamic-forms/internal';
|
|
5
|
-
import { c as computeContainerHostClasses, s as setupContainerInitEffect } from './ng-forge-dynamic-forms-container-utils-BZFz6ztF.mjs';
|
|
3
|
+
import { i as injectFieldRegistry, c as computeContainerHostClasses, a as createFieldResolutionPipe, r as resolveWrappers, b as isSameWrapperChain, s as setupContainerInitEffect, d as initializationComponentPath, e as createWrapperChainController, D as DfFieldOutlet } from './ng-forge-dynamic-forms-ng-forge-dynamic-forms-CXQe_5td.mjs';
|
|
4
|
+
import { EventBus, DynamicFormLogger, WRAPPER_AUTO_ASSOCIATIONS, WRAPPER_REGISTRY, DEFAULT_WRAPPERS, GROUP_CONTEXT, derivedFromDeferred } from '@ng-forge/dynamic-forms/internal';
|
|
6
5
|
|
|
7
6
|
class ContainerFieldComponent {
|
|
8
7
|
destroyRef = inject(DestroyRef);
|
|
@@ -12,9 +11,11 @@ class ContainerFieldComponent {
|
|
|
12
11
|
eventBus = inject(EventBus);
|
|
13
12
|
logger = inject(DynamicFormLogger);
|
|
14
13
|
wrapperAutoAssociations = inject(WRAPPER_AUTO_ASSOCIATIONS);
|
|
14
|
+
wrapperRegistry = inject(WRAPPER_REGISTRY);
|
|
15
15
|
defaultWrappersSignal = inject(DEFAULT_WRAPPERS, { optional: true });
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
groupContext = inject(GROUP_CONTEXT, { optional: true });
|
|
17
|
+
childrenTpl = viewChild.required('childrenTpl', { ...(ngDevMode ? { debugName: "childrenTpl" } : /* istanbul ignore next */ {}), read: TemplateRef });
|
|
18
|
+
wrapperContainer = viewChild.required('wrapperContainer', { ...(ngDevMode ? { debugName: "wrapperContainer" } : /* istanbul ignore next */ {}), read: ViewContainerRef });
|
|
18
19
|
field = input.required(/* @ts-ignore */
|
|
19
20
|
...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
|
|
20
21
|
key = input.required(/* @ts-ignore */
|
|
@@ -43,35 +44,31 @@ class ContainerFieldComponent {
|
|
|
43
44
|
`within container '${containerKey}'. Ensure the field type is registered in your field registry.`, error);
|
|
44
45
|
},
|
|
45
46
|
})), { initialValue: [], injector: this.injector });
|
|
46
|
-
wrappers = computed(() => resolveWrappers(this.field(), this.defaultWrappersSignal?.(), this.wrapperAutoAssociations), { ...(ngDevMode ? { debugName: "wrappers" } : /* istanbul ignore next */ {}), equal: isSameWrapperChain });
|
|
47
|
+
wrappers = computed(() => resolveWrappers(this.field(), this.defaultWrappersSignal?.(), this.wrapperAutoAssociations, this.wrapperRegistry), { ...(ngDevMode ? { debugName: "wrappers" } : /* istanbul ignore next */ {}), equal: isSameWrapperChain });
|
|
47
48
|
constructor() {
|
|
48
|
-
setupContainerInitEffect(this.resolvedFields, this.eventBus, 'container', () => this.field().key, this.injector);
|
|
49
|
+
setupContainerInitEffect(this.resolvedFields, this.eventBus, 'container', () => initializationComponentPath(this.field().key, this.groupContext?.groupPath()), this.injector);
|
|
49
50
|
createWrapperChainController({
|
|
50
51
|
vcr: this.wrapperContainer,
|
|
51
52
|
wrappers: this.wrappers,
|
|
52
53
|
renderInnermost: (slot) => slot.createEmbeddedView(this.childrenTpl()),
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.
|
|
56
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.
|
|
56
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ContainerFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
57
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: ContainerFieldComponent, isStandalone: true, selector: "div[container-field]", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null }, hidden: { classPropertyName: "hidden", publicName: "hidden", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClasses()", "class.disabled": "disabled()", "class.df-container-hidden": "hidden()", "attr.aria-hidden": "hidden() || null", "id": "`${key()}`", "attr.data-testid": "key()" } }, viewQueries: [{ propertyName: "childrenTpl", first: true, predicate: ["childrenTpl"], descendants: true, read: TemplateRef, isSignal: true }, { propertyName: "wrapperContainer", first: true, predicate: ["wrapperContainer"], descendants: true, read: ViewContainerRef, isSignal: true }], ngImport: i0, template: `
|
|
57
58
|
<ng-template #childrenTpl>
|
|
58
59
|
@for (field of resolvedFields(); track field.key) {
|
|
59
|
-
|
|
60
|
-
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
61
|
-
}
|
|
60
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
62
61
|
}
|
|
63
62
|
</ng-template>
|
|
64
63
|
<ng-container #wrapperContainer></ng-container>
|
|
65
64
|
`, isInline: true, styles: [":host{display:block;width:100%}:host.df-container-hidden{display:none}\n"], dependencies: [{ kind: "directive", type: DfFieldOutlet, selector: "[dfFieldOutlet]", inputs: ["dfFieldOutlet", "dfFieldOutletEnvironmentInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
66
65
|
}
|
|
67
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.
|
|
66
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: ContainerFieldComponent, decorators: [{
|
|
68
67
|
type: Component,
|
|
69
68
|
args: [{ selector: 'div[container-field]', imports: [DfFieldOutlet], template: `
|
|
70
69
|
<ng-template #childrenTpl>
|
|
71
70
|
@for (field of resolvedFields(); track field.key) {
|
|
72
|
-
|
|
73
|
-
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
74
|
-
}
|
|
71
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
75
72
|
}
|
|
76
73
|
</ng-template>
|
|
77
74
|
<ng-container #wrapperContainer></ng-container>
|
|
@@ -86,4 +83,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
|
|
|
86
83
|
}], ctorParameters: () => [], propDecorators: { childrenTpl: [{ type: i0.ViewChild, args: ['childrenTpl', { ...{ read: TemplateRef }, isSignal: true }] }], wrapperContainer: [{ type: i0.ViewChild, args: ['wrapperContainer', { ...{ read: ViewContainerRef }, isSignal: true }] }], field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], hidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "hidden", required: false }] }] } });
|
|
87
84
|
|
|
88
85
|
export { ContainerFieldComponent, ContainerFieldComponent as default };
|
|
89
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-container-field.component-
|
|
86
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-container-field.component-Dc3ROhHL.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-container-field.component-Dc3ROhHL.mjs","mappings":";;;;;AAuDc;AACK;;AAEA;AACE;AACF;AACA;AACA;AACA;;;;;;;;;AASjB;;;;AAGS;;AACA;;;;AAGQ;;AAEE;AAGf;AACA;;;AAGA;AACE;;;AAKI;;AAIP;AAIc;AAOjB;AACE;AAQA;;;AAGE;AACD;;;AAnEgB;;;;;;;AAZlB;;;;AATS;;;;;;;;AAYR;AACA;AACA;AACA;AACA;AACA;;AAgBgD","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/fields/container/container-field.component.ts"],"sourcesContent":["import {\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n EnvironmentInjector,\n inject,\n Injector,\n input,\n TemplateRef,\n viewChild,\n ViewContainerRef,\n} from '@angular/core';\nimport { DfFieldOutlet } from '../../directives/df-field-outlet/df-field-outlet.directive';\nimport { derivedFromDeferred } from '@ng-forge/dynamic-forms/internal';\nimport { createFieldResolutionPipe, ResolvedField } from '../../utils/resolve-field/resolve-field';\nimport {\n computeContainerHostClasses,\n initializationComponentPath,\n setupContainerInitEffect,\n} from '../../utils/container-utils/container-utils';\nimport { ContainerField } from '@ng-forge/dynamic-forms/internal';\nimport { injectFieldRegistry } from '../../utils/inject-field-registry/inject-field-registry';\nimport { EventBus } from '@ng-forge/dynamic-forms/internal';\nimport { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormLogger } from '@ng-forge/dynamic-forms/internal';\nimport { WRAPPER_AUTO_ASSOCIATIONS, WRAPPER_REGISTRY } from '@ng-forge/dynamic-forms/internal';\nimport { DEFAULT_WRAPPERS } from '@ng-forge/dynamic-forms/internal';\nimport { isSameWrapperChain, resolveWrappers } from '../../utils/resolve-wrappers/resolve-wrappers';\nimport { createWrapperChainController } from '../../utils/wrapper-chain/wrapper-chain-controller';\nimport { GROUP_CONTEXT } from '@ng-forge/dynamic-forms/internal';\n\n/** Layout container that wraps child fields with UI chrome. */\n@Component({\n selector: 'div[container-field]',\n imports: [DfFieldOutlet],\n template: `\n <ng-template #childrenTpl>\n @for (field of resolvedFields(); track field.key) {\n <ng-container *dfFieldOutlet=\"field; environmentInjector: environmentInjector\" />\n }\n </ng-template>\n <ng-container #wrapperContainer></ng-container>\n `,\n styleUrl: './container-field.component.scss',\n host: {\n '[class]': 'hostClasses()',\n '[class.disabled]': 'disabled()',\n '[class.df-container-hidden]': 'hidden()',\n '[attr.aria-hidden]': 'hidden() || null',\n '[id]': '`${key()}`',\n '[attr.data-testid]': 'key()',\n },\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport default class ContainerFieldComponent {\n private readonly destroyRef = inject(DestroyRef);\n private readonly fieldRegistry = injectFieldRegistry();\n private readonly injector = inject(Injector);\n protected readonly environmentInjector = inject(EnvironmentInjector);\n private readonly eventBus = inject(EventBus);\n private readonly logger = inject(DynamicFormLogger);\n private readonly wrapperAutoAssociations = inject(WRAPPER_AUTO_ASSOCIATIONS);\n private readonly wrapperRegistry = inject(WRAPPER_REGISTRY);\n private readonly defaultWrappersSignal = inject(DEFAULT_WRAPPERS, { optional: true });\n private readonly groupContext = inject(GROUP_CONTEXT, { optional: true });\n\n private readonly childrenTpl = viewChild.required('childrenTpl', { read: TemplateRef });\n private readonly wrapperContainer = viewChild.required('wrapperContainer', { read: ViewContainerRef });\n\n field = input.required<ContainerField>();\n key = input.required<string>();\n className = input<string>();\n hidden = input(false);\n\n readonly hostClasses = computed(() => computeContainerHostClasses('container', this.className()));\n readonly disabled = computed(() => this.field().disabled || false);\n\n private readonly rawFieldRegistry = computed(() => this.fieldRegistry.raw);\n private readonly fieldsSource = computed(() => (this.field().fields || []) as FieldDef<unknown>[]);\n\n protected readonly resolvedFields = derivedFromDeferred(\n this.fieldsSource,\n createFieldResolutionPipe(() => ({\n loadTypeComponent: (type: string) => this.fieldRegistry.loadTypeComponent(type),\n registry: this.rawFieldRegistry(),\n injector: this.injector,\n destroyRef: this.destroyRef,\n onError: (fieldDef: FieldDef<unknown>, error: unknown) => {\n const fieldKey = fieldDef.key || '<no key>';\n\n const containerKey = this.field().key || '<no key>';\n this.logger.error(\n `Failed to load component for field type '${fieldDef.type}' (key: ${fieldKey}) ` +\n `within container '${containerKey}'. Ensure the field type is registered in your field registry.`,\n error,\n );\n },\n })),\n { initialValue: [] as ResolvedField[], injector: this.injector },\n );\n\n private readonly wrappers = computed(\n () => resolveWrappers(this.field(), this.defaultWrappersSignal?.(), this.wrapperAutoAssociations, this.wrapperRegistry),\n {\n equal: isSameWrapperChain,\n },\n );\n\n constructor() {\n setupContainerInitEffect(\n this.resolvedFields,\n this.eventBus,\n 'container',\n () => initializationComponentPath(this.field().key, this.groupContext?.groupPath()),\n this.injector,\n );\n\n createWrapperChainController({\n vcr: this.wrapperContainer,\n wrappers: this.wrappers,\n renderInnermost: (slot) => slot.createEmbeddedView(this.childrenTpl()),\n });\n }\n}\n\nexport { ContainerFieldComponent };\n"]}
|
|
@@ -6,7 +6,7 @@ import { dynamicTextToObservable } from '@ng-forge/dynamic-forms/internal';
|
|
|
6
6
|
|
|
7
7
|
class CssWrapperComponent {
|
|
8
8
|
injector = inject(Injector);
|
|
9
|
-
fieldComponent = viewChild.required('fieldComponent', { read: ViewContainerRef });
|
|
9
|
+
fieldComponent = viewChild.required('fieldComponent', { ...(ngDevMode ? { debugName: "fieldComponent" } : /* istanbul ignore next */ {}), read: ViewContainerRef });
|
|
10
10
|
cssClasses = input(/* @ts-ignore */
|
|
11
11
|
...(ngDevMode ? [undefined, { debugName: "cssClasses" }] : /* istanbul ignore next */ []));
|
|
12
12
|
fieldInputs = input(/* @ts-ignore */
|
|
@@ -14,10 +14,10 @@ class CssWrapperComponent {
|
|
|
14
14
|
resolvedClasses = derivedFrom([this.cssClasses], pipe(switchMap(([value]) => dynamicTextToObservable(value, this.injector))), {
|
|
15
15
|
initialValue: '',
|
|
16
16
|
});
|
|
17
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.
|
|
18
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.
|
|
17
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CssWrapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
18
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "22.1.5", type: CssWrapperComponent, isStandalone: true, selector: "df-css-wrapper", inputs: { cssClasses: { classPropertyName: "cssClasses", publicName: "cssClasses", isSignal: true, isRequired: false, transformFunction: null }, fieldInputs: { classPropertyName: "fieldInputs", publicName: "fieldInputs", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "resolvedClasses()" } }, viewQueries: [{ propertyName: "fieldComponent", first: true, predicate: ["fieldComponent"], descendants: true, read: ViewContainerRef, isSignal: true }], ngImport: i0, template: `<ng-container #fieldComponent></ng-container>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
19
19
|
}
|
|
20
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.
|
|
20
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CssWrapperComponent, decorators: [{
|
|
21
21
|
type: Component,
|
|
22
22
|
args: [{
|
|
23
23
|
selector: 'df-css-wrapper',
|
|
@@ -30,4 +30,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
|
|
|
30
30
|
}], propDecorators: { fieldComponent: [{ type: i0.ViewChild, args: ['fieldComponent', { ...{ read: ViewContainerRef }, isSignal: true }] }], cssClasses: [{ type: i0.Input, args: [{ isSignal: true, alias: "cssClasses", required: false }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
|
|
31
31
|
|
|
32
32
|
export { CssWrapperComponent, CssWrapperComponent as default };
|
|
33
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-css-wrapper.component-
|
|
33
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-css-wrapper.component-BzgX8lAD.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-css-wrapper.component-BzgX8lAD.mjs","mappings":";;;;;;AAiBc;AACK;;AAIR;;AACA;;AAEA;AAIL;AACD;;;;;;AArBM;AACT;AACA;;AAEA;AACE;AACD;AACF;AAI8C","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/wrappers/css/css-wrapper.component.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, inject, Injector, input, Signal, ViewContainerRef, viewChild } from '@angular/core';\nimport { pipe, switchMap } from 'rxjs';\nimport { derivedFrom } from 'ngxtension/derived-from';\nimport { FieldWrapper } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicText } from '@ng-forge/dynamic-forms/internal';\nimport { dynamicTextToObservable } from '@ng-forge/dynamic-forms/internal';\nimport { WrapperFieldInputs } from '@ng-forge/dynamic-forms/internal';\n\n/** Built-in CSS wrapper component. */\n@Component({\n selector: 'df-css-wrapper',\n template: `<ng-container #fieldComponent></ng-container>`,\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n '[class]': 'resolvedClasses()',\n },\n})\nexport default class CssWrapperComponent implements FieldWrapper {\n private readonly injector = inject(Injector);\n\n readonly fieldComponent = viewChild.required('fieldComponent', { read: ViewContainerRef });\n\n readonly cssClasses = input<DynamicText>();\n readonly fieldInputs = input<WrapperFieldInputs>();\n\n readonly resolvedClasses: Signal<string> = derivedFrom(\n [this.cssClasses],\n pipe(switchMap(([value]) => dynamicTextToObservable(value, this.injector))),\n {\n initialValue: '',\n },\n );\n}\n\nexport { CssWrapperComponent };\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
2
|
+
import { debounceTime, distinctUntilChanged, tap } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
5
|
+
function createDebouncedEffect(source, callback, options) {
|
|
6
|
+
const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;
|
|
7
|
+
const source$ = toObservable(source, { injector }).pipe(debounceTime(ms), distinctUntilChanged(), tap((value) => callback(value)));
|
|
8
|
+
return toSignal(source$, { injector });
|
|
9
|
+
}
|
|
10
|
+
function createDebouncedSignal(source, options) {
|
|
11
|
+
const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;
|
|
12
|
+
const source$ = toObservable(source, { injector }).pipe(debounceTime(ms), distinctUntilChanged());
|
|
13
|
+
return toSignal(source$, { injector });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { DEFAULT_DEBOUNCE_MS as D };
|
|
17
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-debounce-C9F4v1Ke.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-debounce-C9F4v1Ke.mjs","mappings":";;;AAKO;;;AAoBL;;AAOF;AAYM;;;;AAMN","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/utils/debounce/debounce.ts"],"sourcesContent":["import { Injector, Signal } from '@angular/core';\nimport { toObservable, toSignal } from '@angular/core/rxjs-interop';\nimport { debounceTime, distinctUntilChanged, tap } from 'rxjs';\n\n/** Default debounce duration in milliseconds. */\nexport const DEFAULT_DEBOUNCE_MS = 500;\n\n/**\n * Creates a debounced effect that triggers a callback after a signal's value\n * has stabilized for the specified duration.\n *\n * @param source - The source signal to watch for changes\n * @param callback - Function to call with the debounced value\n * @param options - Configuration options\n * @param options.ms - Debounce duration in milliseconds (default: 500)\n * @param options.injector - Angular injector for the effect context\n * @returns A signal that emits the debounced value (can be ignored if only side effects are needed)\n */\nexport function createDebouncedEffect<T>(\n source: Signal<T>,\n callback: (value: T) => void,\n options: { ms?: number; injector: Injector },\n): Signal<T | undefined> {\n const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;\n\n const source$ = toObservable(source, { injector }).pipe(\n debounceTime(ms),\n distinctUntilChanged(),\n tap((value) => callback(value)),\n );\n\n return toSignal(source$, { injector });\n}\n\n/**\n * Creates a debounced signal that emits values from the source signal\n * only after the value has stabilized for the specified duration.\n *\n * @param source - The source signal to debounce\n * @param options - Configuration options\n * @param options.ms - Debounce duration in milliseconds (default: 500)\n * @param options.injector - Angular injector for the effect context\n * @returns A signal that emits the debounced value\n */\nexport function createDebouncedSignal<T>(source: Signal<T>, options: { ms?: number; injector: Injector }): Signal<T | undefined> {\n const { ms = DEFAULT_DEBOUNCE_MS, injector } = options;\n\n const source$ = toObservable(source, { injector }).pipe(debounceTime(ms), distinctUntilChanged());\n\n return toSignal(source$, { injector });\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { FieldErrorsWrapperBase, provideFieldErrorDisplay } from '@ng-forge/dynamic-forms/internal';
|
|
4
|
+
|
|
5
|
+
class FieldErrorsWrapperComponent extends FieldErrorsWrapperBase {
|
|
6
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: FieldErrorsWrapperComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
7
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: FieldErrorsWrapperComponent, isStandalone: true, selector: "df-field-errors-wrapper", providers: [provideFieldErrorDisplay(() => FieldErrorsWrapperComponent)], usesInheritance: true, ngImport: i0, template: `
|
|
8
|
+
<ng-container #fieldComponent></ng-container>
|
|
9
|
+
@if (ngf.errorsToDisplay()[0]; as error) {
|
|
10
|
+
<div class="df-field-error" role="alert" [id]="ngf.errorId()">{{ error.message }}</div>
|
|
11
|
+
}
|
|
12
|
+
`, isInline: true, styles: [".df-field-error{color:var(--df-error-color, #dc3545);font-size:var(--df-error-font-size, .875rem);margin-top:var(--df-field-gap, .5rem)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
13
|
+
}
|
|
14
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: FieldErrorsWrapperComponent, decorators: [{
|
|
15
|
+
type: Component,
|
|
16
|
+
args: [{ selector: 'df-field-errors-wrapper', template: `
|
|
17
|
+
<ng-container #fieldComponent></ng-container>
|
|
18
|
+
@if (ngf.errorsToDisplay()[0]; as error) {
|
|
19
|
+
<div class="df-field-error" role="alert" [id]="ngf.errorId()">{{ error.message }}</div>
|
|
20
|
+
}
|
|
21
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, providers: [provideFieldErrorDisplay(() => FieldErrorsWrapperComponent)], styles: [".df-field-error{color:var(--df-error-color, #dc3545);font-size:var(--df-error-font-size, .875rem);margin-top:var(--df-field-gap, .5rem)}\n"] }]
|
|
22
|
+
}] });
|
|
23
|
+
|
|
24
|
+
export { FieldErrorsWrapperComponent, FieldErrorsWrapperComponent as default };
|
|
25
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-field-errors-wrapper.component-CIKbWqYA.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-field-errors-wrapper.component-CIKbWqYA.mjs","mappings":";;;;AAmBc;;;;;;;AALX;;;;AANS","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/wrappers/field-errors/field-errors-wrapper.component.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component } from '@angular/core';\nimport { FieldErrorsWrapperBase, provideFieldErrorDisplay } from '@ng-forge/dynamic-forms/internal';\n\n/**\n * Neutral default rendering of a validation message for the wrapped field.\n * Follows the `--df-error-*` conventions; adapters register their own under the same name.\n */\n@Component({\n selector: 'df-field-errors-wrapper',\n template: `\n <ng-container #fieldComponent></ng-container>\n @if (ngf.errorsToDisplay()[0]; as error) {\n <div class=\"df-field-error\" role=\"alert\" [id]=\"ngf.errorId()\">{{ error.message }}</div>\n }\n `,\n styleUrl: './field-errors-wrapper.component.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [provideFieldErrorDisplay(() => FieldErrorsWrapperComponent)],\n})\nexport default class FieldErrorsWrapperComponent extends FieldErrorsWrapperBase {}\n\nexport { FieldErrorsWrapperComponent };\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Service } from '@angular/core';
|
|
3
|
+
import { DynamicFormError } from '@ng-forge/dynamic-forms/internal';
|
|
4
|
+
import { c as createSchemaFromFields, a as createFormLevelSchema } from './ng-forge-dynamic-forms-schema-builder-BvDTzfdM.mjs';
|
|
5
|
+
|
|
6
|
+
class FormSchemaService {
|
|
7
|
+
create({ fields, registry, formLevelSchema, validateWhenHidden }) {
|
|
8
|
+
if (fields.length > 0) {
|
|
9
|
+
return createSchemaFromFields(fields, registry, { formLevelSchema, validateWhenHidden });
|
|
10
|
+
}
|
|
11
|
+
if (formLevelSchema) {
|
|
12
|
+
return createFormLevelSchema(formLevelSchema);
|
|
13
|
+
}
|
|
14
|
+
throw new DynamicFormError('The form schema service was requested without fields or a form-level schema.');
|
|
15
|
+
}
|
|
16
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: FormSchemaService, deps: [], target: i0.ɵɵFactoryTarget.Service });
|
|
17
|
+
static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.1.5", ngImport: i0, type: FormSchemaService });
|
|
18
|
+
}
|
|
19
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: FormSchemaService, decorators: [{
|
|
20
|
+
type: Service
|
|
21
|
+
}] });
|
|
22
|
+
|
|
23
|
+
export { FormSchemaService as default };
|
|
24
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-form-schema.service-Dx5Dlb15.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-form-schema.service-Dx5Dlb15.mjs","mappings":";;;;;AAsBc;;AAEV;AACE;;;AAIA;;AAGF","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/core/form-schema.service.ts"],"sourcesContent":["import { Service } from '@angular/core';\nimport type { Schema } from '@angular/forms/signals';\nimport { DynamicFormError } from '@ng-forge/dynamic-forms/internal';\nimport type { FieldDef, FieldTypeDefinition } from '@ng-forge/dynamic-forms/internal';\nimport type { FormSchema } from '@ng-forge/dynamic-forms/schema';\nimport { createFormLevelSchema } from './form-schema-merger';\nimport { createSchemaFromFields } from './schema-builder';\n\nexport interface FormSchemaRequest {\n readonly fields: FieldDef<unknown>[];\n readonly registry: Map<string, FieldTypeDefinition>;\n readonly formLevelSchema: FormSchema<unknown> | undefined;\n readonly validateWhenHidden: boolean | undefined;\n}\n\n/**\n * Lazy schema compiler requested by a form when its first value-bearing field is encountered.\n *\n * The service is stateless and auto-provided so Angular's `injectAsync()` can keep this module,\n * including form mapping, validators, schemas, and expression logic, out of the eager graph.\n */\n@Service()\nexport default class FormSchemaService {\n create({ fields, registry, formLevelSchema, validateWhenHidden }: FormSchemaRequest): Schema<unknown> {\n if (fields.length > 0) {\n return createSchemaFromFields(fields, registry, { formLevelSchema, validateWhenHidden });\n }\n\n if (formLevelSchema) {\n return createFormLevelSchema(formLevelSchema);\n }\n\n throw new DynamicFormError('The form schema service was requested without fields or a form-level schema.');\n }\n}\n"]}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { inject, DestroyRef, Injector, EnvironmentInjector, input, computed, linkedSignal, runInInjectionContext, untracked, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
-
import { i as injectFieldRegistry, C as CONTAINER_FIELD_PROCESSORS,
|
|
3
|
+
import { i as injectFieldRegistry, C as CONTAINER_FIELD_PROCESSORS, c as computeContainerHostClasses, a as createFieldResolutionPipe, s as setupContainerInitEffect, d as initializationComponentPath, D as DfFieldOutlet } from './ng-forge-dynamic-forms-ng-forge-dynamic-forms-CXQe_5td.mjs';
|
|
4
4
|
import { outputFromObservable, toObservable } from '@angular/core/rxjs-interop';
|
|
5
5
|
import { explicitEffect } from 'ngxtension/explicit-effect';
|
|
6
6
|
import { injectFieldSignalContext, GROUP_CONTEXT, EventBus, DynamicFormLogger, isEqual, FIELD_SIGNAL_CONTEXT, derivedFromDeferred } from '@ng-forge/dynamic-forms/internal';
|
|
7
|
-
import { c as computeContainerHostClasses, s as setupContainerInitEffect } from './ng-forge-dynamic-forms-container-utils-BZFz6ztF.mjs';
|
|
8
7
|
import { form } from '@angular/forms/signals';
|
|
8
|
+
import { c as createSchemaFromFields } from './ng-forge-dynamic-forms-schema-builder-BvDTzfdM.mjs';
|
|
9
9
|
|
|
10
10
|
class GroupFieldComponent {
|
|
11
11
|
destroyRef = inject(DestroyRef);
|
|
@@ -142,29 +142,25 @@ class GroupFieldComponent {
|
|
|
142
142
|
this.setupEffects();
|
|
143
143
|
}
|
|
144
144
|
setupEffects() {
|
|
145
|
-
setupContainerInitEffect(this.resolvedFields, this.eventBus, 'group', () => this.field().key, this.injector);
|
|
145
|
+
setupContainerInitEffect(this.resolvedFields, this.eventBus, 'group', () => initializationComponentPath(this.field().key, this.parentGroupContext?.groupPath()), this.injector);
|
|
146
146
|
explicitEffect([this.nestedFieldTree, this.field], ([tree, field]) => {
|
|
147
147
|
if (!tree) {
|
|
148
148
|
this.logger.warn(`Group field "${field.key}" not found in parent form. ` + `Ensure the parent form schema includes this group field.`);
|
|
149
149
|
}
|
|
150
150
|
}, { defer: true });
|
|
151
151
|
}
|
|
152
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.
|
|
153
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.
|
|
152
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: GroupFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
153
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: GroupFieldComponent, isStandalone: true, selector: "fieldset[group-field]", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null }, hidden: { classPropertyName: "hidden", publicName: "hidden", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { validityChange: "validityChange", dirtyChange: "dirtyChange", submitted: "submitted" }, host: { attributes: { "role": "group" }, properties: { "class": "hostClasses()", "class.disabled": "disabled()", "class.df-container-hidden": "hidden()", "attr.aria-hidden": "hidden() || null", "id": "`${key()}`", "attr.data-testid": "key()" } }, ngImport: i0, template: `
|
|
154
154
|
@for (field of resolvedFields(); track field.key) {
|
|
155
|
-
|
|
156
|
-
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
157
|
-
}
|
|
155
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
158
156
|
}
|
|
159
157
|
`, isInline: true, styles: [".df-form{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}.df-form>*{grid-column:1/-1}.df-row{display:grid;grid-template-columns:repeat(var(--df-grid-columns, 12),1fr);gap:var(--df-grid-gap, .5rem);align-items:start;width:100%}.df-row>*:not([class*=df-col-]){grid-column:1/-1}.df-col-1{grid-column:span 1}.df-col-2{grid-column:span 2}.df-col-3{grid-column:span 3}.df-col-4{grid-column:span 4}.df-col-5{grid-column:span 5}.df-col-6{grid-column:span 6}.df-col-7{grid-column:span 7}.df-col-8{grid-column:span 8}.df-col-9{grid-column:span 9}.df-col-10{grid-column:span 10}.df-col-11{grid-column:span 11}.df-col-12{grid-column:span 12}.df-col-auto{grid-column:span auto;width:auto}.df-col-full{grid-column:1/-1}.df-col-start-1{grid-column-start:1}.df-col-start-2{grid-column-start:2}.df-col-start-3{grid-column-start:3}.df-col-start-4{grid-column-start:4}.df-col-start-5{grid-column-start:5}.df-col-start-6{grid-column-start:6}.df-col-start-7{grid-column-start:7}.df-col-start-8{grid-column-start:8}.df-col-start-9{grid-column-start:9}.df-col-start-10{grid-column-start:10}.df-col-start-11{grid-column-start:11}.df-col-start-12{grid-column-start:12}.df-col-end-1{grid-column-end:1}.df-col-end-2{grid-column-end:2}.df-col-end-3{grid-column-end:3}.df-col-end-4{grid-column-end:4}.df-col-end-5{grid-column-end:5}.df-col-end-6{grid-column-end:6}.df-col-end-7{grid-column-end:7}.df-col-end-8{grid-column-end:8}.df-col-end-9{grid-column-end:9}.df-col-end-10{grid-column-end:10}.df-col-end-11{grid-column-end:11}.df-col-end-12{grid-column-end:12}.df-col-end-13{grid-column-end:13}@media(max-width:576px){.df-form{--df-grid-gap: var(--df-grid-gap-sm, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-sm, .5rem)}.df-row{grid-template-columns:1fr}.df-row>*{grid-column:1/-1!important}.df-row.df-row-mobile-keep-cols{grid-template-columns:repeat(var(--df-grid-columns, 12),1fr)}.df-row.df-row-mobile-keep-cols>*{grid-column:revert!important}}@media(min-width:577px)and (max-width:768px){.df-form{--df-grid-gap: var(--df-grid-gap-md, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-md, .5rem)}.df-row{--df-grid-columns: 6}.df-col-sm-1{grid-column:span 1}.df-col-sm-2{grid-column:span 2}.df-col-sm-3{grid-column:span 3}.df-col-sm-4{grid-column:span 4}.df-col-sm-5{grid-column:span 5}.df-col-sm-6{grid-column:span 6}.df-col-sm-full{grid-column:1/-1}}@media(min-width:769px)and (max-width:992px){.df-form{--df-grid-gap: var(--df-grid-gap-lg, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-lg, .5rem)}.df-col-md-1{grid-column:span 1}.df-col-md-2{grid-column:span 2}.df-col-md-3{grid-column:span 3}.df-col-md-4{grid-column:span 4}.df-col-md-5{grid-column:span 5}.df-col-md-6{grid-column:span 6}.df-col-md-7{grid-column:span 7}.df-col-md-8{grid-column:span 8}.df-col-md-9{grid-column:span 9}.df-col-md-10{grid-column:span 10}.df-col-md-11{grid-column:span 11}.df-col-md-12{grid-column:span 12}.df-col-md-full{grid-column:1/-1}}@media(min-width:993px){.df-form{--df-grid-gap: var(--df-grid-gap-xl, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-xl, .5rem)}.df-col-lg-1{grid-column:span 1}.df-col-lg-2{grid-column:span 2}.df-col-lg-3{grid-column:span 3}.df-col-lg-4{grid-column:span 4}.df-col-lg-5{grid-column:span 5}.df-col-lg-6{grid-column:span 6}.df-col-lg-7{grid-column:span 7}.df-col-lg-8{grid-column:span 8}.df-col-lg-9{grid-column:span 9}.df-col-lg-10{grid-column:span 10}.df-col-lg-11{grid-column:span 11}.df-col-lg-12{grid-column:span 12}.df-col-lg-full{grid-column:1/-1}}.df-gap-none{--df-grid-gap: 0}.df-gap-xs{--df-grid-gap: .25rem}.df-gap-sm{--df-grid-gap: .5rem}.df-gap-md{--df-grid-gap: 1rem}.df-gap-lg{--df-grid-gap: 1.5rem}.df-gap-xl{--df-grid-gap: 2rem}.df-row-gap-none{--df-grid-row-gap: 0}.df-row-gap-xs{--df-grid-row-gap: .25rem}.df-row-gap-sm{--df-grid-row-gap: .5rem}.df-row-gap-md{--df-grid-row-gap: 1rem}.df-row-gap-lg{--df-grid-row-gap: 1.5rem}.df-row-gap-xl{--df-grid-row-gap: 2rem}.df-field{display:block;width:100%;min-width:0;overflow:hidden;margin:0}.df-group,.df-page{display:block;width:100%}.df-form.disabled,.df-row.disabled,.df-field.disabled{opacity:.6;pointer-events:none}.df-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-sr-only-focusable:focus,.df-sr-only-focusable:active{position:static;width:auto;height:auto;padding:inherit;margin:inherit;overflow:visible;clip:auto;white-space:normal}.df-live-region{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-form :focus-visible{outline:2px solid var(--df-focus-color, #005fcc);outline-offset:2px}.df-group:focus-within{outline:1px dashed var(--df-focus-color, #005fcc);outline-offset:4px}:host{border:none;margin:0;padding:0;min-width:0;display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}:host>*{grid-column:1/-1}:host.df-container-hidden{display:none}\n"], dependencies: [{ kind: "directive", type: DfFieldOutlet, selector: "[dfFieldOutlet]", inputs: ["dfFieldOutlet", "dfFieldOutletEnvironmentInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
160
158
|
}
|
|
161
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.
|
|
159
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: GroupFieldComponent, decorators: [{
|
|
162
160
|
type: Component,
|
|
163
161
|
args: [{ selector: 'fieldset[group-field]', imports: [DfFieldOutlet], template: `
|
|
164
162
|
@for (field of resolvedFields(); track field.key) {
|
|
165
|
-
|
|
166
|
-
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
167
|
-
}
|
|
163
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
168
164
|
}
|
|
169
165
|
`, host: {
|
|
170
166
|
'[class]': 'hostClasses()',
|
|
@@ -178,4 +174,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
|
|
|
178
174
|
}], ctorParameters: () => [], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], hidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "hidden", required: false }] }], validityChange: [{ type: i0.Output, args: ["validityChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], submitted: [{ type: i0.Output, args: ["submitted"] }] } });
|
|
179
175
|
|
|
180
176
|
export { GroupFieldComponent, GroupFieldComponent as default };
|
|
181
|
-
//# sourceMappingURL=ng-forge-dynamic-forms-group-field.component-
|
|
177
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-group-field.component-DAC3P9DC.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ng-forge-dynamic-forms-group-field.component-DAC3P9DC.mjs","mappings":";;;;;;;;;AA4Dc;YAKK;;;;AAIA;AACE;AACF;AACA;YAMA;;;;;AAQjB;;;;YAOS;;;;AAIQ;AACf;AACA;AAEA;AACE;AACA;;AAEA;;AAGE;AACA;;;;;;;AAQF;AACA;AACA;AACA;;;;;;;AAOJ,IAKiB;;;AAIb;;AAEA;AACF;AAEE;AACA;AACD;AAGc;AACf;AACE;;AAGE;AACA;;AAGF;AACF;;;;;AAQO;;AACA;;AACA;;AACA;;AACA;;AACA;;AAEQ;AACf;;AAEA;;;;AAIA;;;AAGE;;AAEA;AACE;;;;AAKN,IAMiB;AACf;;;AAGE;AACF;;AAGe;;AAEf;;;AAGC;AACF;;;AAQQ;;;AAQU;AAGf;AACA;;;AAGA;AACE;;;;AAOH;YAQH;;;;AASE;AAQA;;AAIM;;AAIJ;;;AArNe;;;;AAhBlB;;;;AANS;;;;;AASR;AACA;AACA;AACA;AACA;wEAIA;AACA","names":[],"ignoreList":[],"sources":["../../../../packages/dynamic-forms/src/lib/fields/group/group-field.component.ts"],"sourcesContent":["import {\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n EnvironmentInjector,\n inject,\n Injector,\n input,\n linkedSignal,\n runInInjectionContext,\n untracked,\n} from '@angular/core';\nimport { DfFieldOutlet } from '../../directives/df-field-outlet/df-field-outlet.directive';\nimport { outputFromObservable, toObservable } from '@angular/core/rxjs-interop';\nimport { explicitEffect } from 'ngxtension/explicit-effect';\nimport { derivedFromDeferred } from '@ng-forge/dynamic-forms/internal';\nimport { createFieldResolutionPipe, ResolvedField } from '../../utils/resolve-field/resolve-field';\nimport {\n computeContainerHostClasses,\n initializationComponentPath,\n setupContainerInitEffect,\n} from '../../utils/container-utils/container-utils';\nimport { CONTAINER_FIELD_PROCESSORS } from '../../utils/container-utils/container-field-processors';\nimport { isEqual } from '@ng-forge/dynamic-forms/internal';\nimport { DynamicFormLogger } from '@ng-forge/dynamic-forms/internal';\nimport { GroupField } from '@ng-forge/dynamic-forms/internal';\nimport { injectFieldRegistry } from '../../utils/inject-field-registry/inject-field-registry';\nimport { FieldTree, form } from '@angular/forms/signals';\nimport { FieldDef } from '@ng-forge/dynamic-forms/internal';\nimport { FieldSignalContext, GroupContext } from '@ng-forge/dynamic-forms/internal';\nimport { FIELD_SIGNAL_CONTEXT, GROUP_CONTEXT, injectFieldSignalContext } from '@ng-forge/dynamic-forms/internal';\nimport { createSchemaFromFields } from '../../core/schema-builder';\nimport { EventBus } from '@ng-forge/dynamic-forms/internal';\nimport { FormSubmitEvent } from '../../events/constants/submit.event';\n\n/** Container component for rendering nested form groups. */\n@Component({\n selector: 'fieldset[group-field]',\n imports: [DfFieldOutlet],\n template: `\n @for (field of resolvedFields(); track field.key) {\n <ng-container *dfFieldOutlet=\"field; environmentInjector: environmentInjector\" />\n }\n `,\n styleUrl: './group-field.component.scss',\n host: {\n '[class]': 'hostClasses()',\n role: 'group',\n '[class.disabled]': 'disabled()',\n '[class.df-container-hidden]': 'hidden()',\n '[attr.aria-hidden]': 'hidden() || null',\n // The group element's own id is the plain `key()` — only its DESCENDANTS\n // compose scoped ids via GROUP_CONTEXT (e.g. children of an `address`\n // group render as `address_street`). The group selector is `#address`.\n '[id]': '`${key()}`',\n '[attr.data-testid]': 'key()',\n },\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport default class GroupFieldComponent<TModel extends Record<string, unknown> = Record<string, unknown>> {\n // ─────────────────────────────────────────────────────────────────────────────\n // Dependencies\n // ─────────────────────────────────────────────────────────────────────────────\n\n private readonly destroyRef = inject(DestroyRef);\n private readonly fieldRegistry = injectFieldRegistry();\n private readonly parentFieldSignalContext = injectFieldSignalContext<TModel>();\n private readonly parentGroupContext = inject(GROUP_CONTEXT, { optional: true });\n private readonly injector = inject(Injector);\n protected readonly environmentInjector = inject(EnvironmentInjector);\n private readonly eventBus = inject(EventBus);\n private readonly logger = inject(DynamicFormLogger);\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Memoized Functions\n // ─────────────────────────────────────────────────────────────────────────────\n\n private readonly fieldProcessors = inject(CONTAINER_FIELD_PROCESSORS);\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Inputs\n // ─────────────────────────────────────────────────────────────────────────────\n\n field = input.required<GroupField>();\n key = input.required<string>();\n className = input<string>();\n hidden = input(false);\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Computed Signals\n // ─────────────────────────────────────────────────────────────────────────────\n\n readonly hostClasses = computed(() => computeContainerHostClasses('group', this.className()));\n\n private readonly rawFieldRegistry = computed(() => this.fieldRegistry.raw);\n\n private readonly formSetup = computed(() => {\n const groupField = this.field();\n const registry = this.rawFieldRegistry();\n\n if (groupField.fields && groupField.fields.length > 0) {\n const flattenedFields = this.fieldProcessors.memoizedFlattenFields(groupField.fields, registry);\n const flattenedFieldsForRendering = this.fieldProcessors.memoizedFlattenFieldsForRendering(groupField.fields, registry);\n const fieldsById = this.fieldProcessors.memoizedKeyBy(flattenedFields);\n const defaultValues = this.fieldProcessors.memoizedDefaultValues(fieldsById, registry);\n\n return {\n fields: flattenedFieldsForRendering,\n schemaFields: flattenedFields,\n originalFields: groupField.fields,\n defaultValues,\n registry,\n };\n }\n\n return {\n fields: [],\n schemaFields: [],\n originalFields: [],\n defaultValues: {},\n registry,\n };\n });\n\n readonly defaultValues = linkedSignal(() => this.formSetup().defaultValues);\n\n /**\n * Entity computed from parent value, group key, and defaults.\n * Uses deep equality check to prevent unnecessary updates when\n * object spread creates new references with identical values.\n */\n private readonly entity = linkedSignal(\n () => {\n const parentValue = this.parentFieldSignalContext.value();\n const groupKey = this.field().key;\n const defaults = this.defaultValues();\n const groupValue = (parentValue as Record<string, unknown>)?.[groupKey] || {};\n return { ...defaults, ...groupValue };\n },\n {\n debugName: 'GroupFieldComponent.entity',\n equal: isEqual,\n },\n );\n\n private readonly form = computed(() => {\n return runInInjectionContext(this.injector, () => {\n const setup = this.formSetup();\n\n if (setup.schemaFields.length > 0) {\n const schema = createSchemaFromFields(setup.schemaFields, setup.registry);\n return untracked(() => form(this.entity, schema));\n }\n\n return untracked(() => form(this.entity));\n });\n });\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Public State Signals\n // ─────────────────────────────────────────────────────────────────────────────\n\n readonly formValue = computed(() => this.entity());\n readonly valid = computed(() => this.form()().valid());\n readonly invalid = computed(() => this.form()().invalid());\n readonly dirty = computed(() => this.form()().dirty());\n readonly touched = computed(() => this.form()().touched());\n readonly errors = computed(() => this.form()().errors());\n readonly disabled = computed(() => this.form()().disabled());\n\n private readonly nestedFieldTree = computed((): FieldTree<Record<string, unknown>> | undefined => {\n const parentForm = this.parentFieldSignalContext.form as Record<string, FieldTree<Record<string, unknown>>>;\n const groupKey = this.field().key;\n return parentForm[groupKey];\n });\n\n private readonly groupFieldSignalContext: FieldSignalContext<Record<string, unknown>> = (() => {\n const nestedFieldTree = this.nestedFieldTree;\n return {\n injector: this.injector,\n value: this.parentFieldSignalContext.value,\n defaultValues: this.defaultValues,\n get form() {\n return nestedFieldTree() ?? ({} as FieldTree<Record<string, unknown>>);\n },\n };\n })();\n\n /**\n * Group context propagated to descendants — carries the dot-separated path of\n * group ancestors so child fields can scope their DOM IDs (issue #401).\n * Reactive via `computed()`: a nested group composes its path from the parent\n * context signal + its own key, so the chain stays consistent if either changes.\n */\n private readonly groupContext: GroupContext = {\n groupPath: computed(() => {\n const ownKey = this.field().key;\n const parentPath = this.parentGroupContext?.groupPath();\n return parentPath ? `${parentPath}.${ownKey}` : ownKey;\n }),\n };\n\n private readonly groupInjector = Injector.create({\n parent: this.injector,\n providers: [\n { provide: FIELD_SIGNAL_CONTEXT, useValue: this.groupFieldSignalContext },\n { provide: GROUP_CONTEXT, useValue: this.groupContext },\n ],\n });\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Outputs\n // ─────────────────────────────────────────────────────────────────────────────\n\n readonly validityChange = outputFromObservable(toObservable(this.valid));\n readonly dirtyChange = outputFromObservable(toObservable(this.dirty));\n readonly submitted = outputFromObservable(this.eventBus.on<FormSubmitEvent>('submit'));\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Field Resolution\n // ─────────────────────────────────────────────────────────────────────────────\n\n private readonly fieldsSource = computed(() => this.formSetup().fields);\n\n protected readonly resolvedFields = derivedFromDeferred(\n this.fieldsSource,\n createFieldResolutionPipe(() => ({\n loadTypeComponent: (type: string) => this.fieldRegistry.loadTypeComponent(type),\n registry: this.rawFieldRegistry(),\n injector: this.groupInjector,\n destroyRef: this.destroyRef,\n onError: (fieldDef: FieldDef<unknown>, error: unknown) => {\n const fieldKey = fieldDef.key || '<no key>';\n this.logger.error(\n `Failed to load component for field type '${fieldDef.type}' (key: ${fieldKey}) ` +\n `within group '${this.field().key}'. Ensure the field type is registered in your field registry.`,\n error,\n );\n },\n })),\n { initialValue: [] as ResolvedField[], injector: this.injector },\n );\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Constructor\n // ─────────────────────────────────────────────────────────────────────────────\n\n constructor() {\n this.setupEffects();\n }\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Private Methods\n // ─────────────────────────────────────────────────────────────────────────────\n\n private setupEffects(): void {\n setupContainerInitEffect(\n this.resolvedFields,\n this.eventBus,\n 'group',\n () => initializationComponentPath(this.field().key, this.parentGroupContext?.groupPath()),\n this.injector,\n );\n\n explicitEffect(\n [this.nestedFieldTree, this.field],\n ([tree, field]) => {\n if (!tree) {\n this.logger.warn(\n `Group field \"${field.key}\" not found in parent form. ` + `Ensure the parent form schema includes this group field.`,\n );\n }\n },\n { defer: true },\n );\n }\n}\n\nexport { GroupFieldComponent };\n"]}
|