ngx-dynamic-entity 2.1.1 → 2.3.0

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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormGroup, FormArray, FormsModule } from '@angular/forms';
5
- import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, getTabPath, fieldRefFor, toRefToken, refOf, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, collectFieldScopes, applyAutoPatch, isUnsafePath, fieldsUnderTab, valuesEqual, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
5
+ import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, isValidUrl, isValidPhone, fileRefName, sliderBounds, ratingScale, normalizeHexColor, normalizeTags, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, fieldsUnderTab, getTabPath, fieldRefFor, flattenFieldValues, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, toRefToken, collectFieldScopes, applyAutoPatch, refOf, isUnsafePath, valuesEqual, parseCsv, arrayBoundFor, deriveImportColumns, suggestMapping, cellText, applyMapping, decimalMarkFor, toCsv, stripIndices, CORE_VERSION, DEFAULT_ARRAY_ROWS, upgradeLegacyRefs, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
6
6
  export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
7
7
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
8
  import { Subject, isObservable, firstValueFrom } from 'rxjs';
@@ -302,6 +302,10 @@ const DEFAULT_VALIDATION_MESSAGES = {
302
302
  required: 'This field is required.',
303
303
  requiredSelection: 'Please select an option.',
304
304
  email: 'Please enter a valid email address.',
305
+ url: 'Please enter a full web address, starting with https://.',
306
+ phone: 'Please enter a valid phone number.',
307
+ minItems: (_lang, err) => `At least ${err?.requiredLength} required.`,
308
+ maxItems: (_lang, err) => `No more than ${err?.requiredLength} allowed.`,
305
309
  pattern: 'Invalid format.',
306
310
  minlength: (_lang, err) => `Minimum ${err?.requiredLength} characters required.`,
307
311
  maxlength: (_lang, err) => `Maximum ${err?.requiredLength} characters allowed.`,
@@ -318,18 +322,35 @@ const DEFAULT_VALIDATION_MESSAGES = {
318
322
  * first error actually on the control — so the shared list costs nothing and spares every
319
323
  * field component a near-identical copy of it.
320
324
  */
321
- const COMMON_ORDER = ['required', 'email', 'min', 'max', 'minlength', 'maxlength', 'pattern'];
325
+ const COMMON_ORDER = [
326
+ 'required',
327
+ 'email',
328
+ 'url',
329
+ 'phone',
330
+ 'min',
331
+ 'max',
332
+ 'minlength',
333
+ 'maxlength',
334
+ 'pattern',
335
+ ];
322
336
  /**
323
337
  * Where a field type disagrees with the common list.
324
338
  *
325
- * Only three do, and each for a reason about how the control reads rather than what it can
326
- * raise: a select says "Please select an option" where an input says "This field is
327
- * required"; a number that is neither required nor out of range is not "invalid", it is not a
328
- * number; and a control with no text in it cannot fail a length or format rule.
339
+ * Each for a reason about how the control reads rather than what it can raise: a select says
340
+ * "Please select an option" where an input says "This field is required"; a number that is
341
+ * neither required nor out of range is not "invalid", it is not a number; a control with no
342
+ * text in it cannot fail a length or format rule; and a `tags` list is counted in items, not
343
+ * characters, although Angular reports both as `minlength`.
329
344
  */
330
345
  const ORDER_BY_FIELD_TYPE = {
331
346
  dropdown: { order: [['required', 'requiredSelection']], fallback: 'invalidSelection' },
332
347
  number: { order: ['required', 'min', 'max'], fallback: 'invalidNumber' },
348
+ slider: { order: ['required', 'min', 'max'], fallback: 'invalid' },
349
+ rating: { order: ['required', 'min', 'max'], fallback: 'invalid' },
350
+ tags: {
351
+ order: ['required', ['minlength', 'minItems'], ['maxlength', 'maxItems'], 'pattern'],
352
+ fallback: 'invalid',
353
+ },
333
354
  boolean: { order: ['required', 'pattern'], fallback: 'invalid' },
334
355
  checkbox: { order: ['required', 'pattern'], fallback: 'invalid' },
335
356
  file: { order: ['required', 'pattern'], fallback: 'invalid' },
@@ -854,6 +875,11 @@ const DEFAULT_UI_TEXT = {
854
875
  noImage: 'No image',
855
876
  uploadImage: 'Upload',
856
877
  changeImage: 'Change',
878
+ // Rating, colour and tags
879
+ ratingValue: '{value} of {max}',
880
+ clearValue: 'Clear {field}',
881
+ addTagPlaceholder: 'Type and press Enter',
882
+ removeTag: 'Remove {tag}',
857
883
  // Month / year pickers
858
884
  month: 'Month',
859
885
  year: 'Year',
@@ -894,8 +920,16 @@ const DEFAULT_UI_TEXT = {
894
920
  importGuessed: 'Guessed',
895
921
  importMappedCount: '{mapped} of {total} field(s) matched.',
896
922
  importRequiredUnmapped: '{count} required field(s) still need a column.',
923
+ importConstant: 'Fixed value: {value}',
924
+ importAddSlot: 'Add a {label} row',
925
+ importStaleHeading: 'Mapped to fields this form no longer has',
926
+ importStaleExplain: 'The import will refuse this mapping until these are removed.',
927
+ importRemoveEntry: 'Remove',
897
928
  // Import wizard — review and result
898
929
  importPreviewHeading: 'First {count} row(s), as they will be saved',
930
+ importDelimiterComma: 'Columns are separated by commas.',
931
+ importDelimiterSemicolon: 'Columns are separated by semicolons, and numbers use a decimal comma.',
932
+ importDelimiterTab: 'Columns are separated by tabs.',
899
933
  importRowRejected: 'This row will not be imported: {reason}',
900
934
  importRunImport: 'Import {count} row(s)',
901
935
  importImporting: 'Importing…',
@@ -5195,6 +5229,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
5195
5229
  type: Input
5196
5230
  }] } });
5197
5231
 
5232
+ /**
5233
+ * A format check that, like `Validators.email`, passes an empty value.
5234
+ *
5235
+ * Emptiness is `required`'s question. Answering it here too would give an optional field an
5236
+ * error the moment it was cleared.
5237
+ */
5238
+ function formatValidator(key, test) {
5239
+ return (control) => {
5240
+ const value = control.value;
5241
+ if (value === null || value === undefined || value === '')
5242
+ return null;
5243
+ return test(String(value)) ? null : { [key]: true };
5244
+ };
5245
+ }
5246
+ /** The built-in web address check. The rule itself is in core, so an import applies the same one. */
5247
+ const urlValidator = formatValidator('url', isValidUrl);
5248
+ /** The built-in phone number check. The rule itself is in core, so an import applies the same one. */
5249
+ const phoneValidator = formatValidator('phone', isValidPhone);
5198
5250
  /**
5199
5251
  * ValidatorRegistryService — resolves validator configs / key strings to Angular ValidatorFns.
5200
5252
  */
@@ -5213,6 +5265,10 @@ class ValidatorRegistryService {
5213
5265
  return Validators.required;
5214
5266
  if (validatorKey === 'email')
5215
5267
  return Validators.email;
5268
+ if (validatorKey === 'url')
5269
+ return urlValidator;
5270
+ if (validatorKey === 'phone')
5271
+ return phoneValidator;
5216
5272
  const [name, param] = validatorKey.split(':');
5217
5273
  const value = parseFloat(param);
5218
5274
  if (name === 'min' && !isNaN(value))
@@ -5262,6 +5318,10 @@ class ValidatorRegistryService {
5262
5318
  }
5263
5319
  if (config.email)
5264
5320
  fnList.push(Validators.email);
5321
+ if (config.url)
5322
+ fnList.push(urlValidator);
5323
+ if (config.phone)
5324
+ fnList.push(phoneValidator);
5265
5325
  // Named validators from the consumer registry, so a registered validator can be named
5266
5326
  // from a typed schema.
5267
5327
  for (const key of config.custom ?? []) {
@@ -6152,239 +6212,1756 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
6152
6212
  }] } });
6153
6213
 
6154
6214
  /**
6155
- * Field-type registration — the tree-shaking seam.
6156
- *
6157
- * `FieldRegistryService` imports no components. Whatever you register here is what gets
6158
- * bundled, so an app that only uses text/number/dropdown pays for three components and
6159
- * their imports, not twenty-one.
6160
- *
6161
- * ```ts
6162
- * // Everything (convenient; bundles all built-ins):
6163
- * provideNgxDynamicEntity(), provideBuiltInFieldTypes()
6164
- *
6165
- * // Only what this app renders:
6166
- * provideFieldTypes({ text: TextFieldComponent, number: NumberFieldComponent })
6167
- * ```
6168
- *
6169
- * Both merge into `FIELD_TYPE_REGISTRY`; later registrations win on key collision.
6170
- */
6171
- /** Map of every built-in field type key → component. Referencing this pulls in all of them. */
6172
- function builtInFieldTypes() {
6173
- return {
6174
- text: TextFieldComponent,
6175
- textarea: TextareaFieldComponent,
6176
- markdown: MarkdownFieldComponent,
6177
- number: NumberFieldComponent,
6178
- currency: CurrencyFieldComponent,
6179
- email: EmailFieldComponent,
6180
- password: PasswordFieldComponent,
6181
- checkbox: CheckboxFieldComponent,
6182
- boolean: BooleanFieldComponent,
6183
- date: DateFieldComponent,
6184
- datetime: DateTimeFieldComponent,
6185
- time: TimeFieldComponent,
6186
- monthYear: MonthYearFieldComponent,
6187
- dropdown: DropdownFieldComponent,
6188
- radio: RadioFieldComponent,
6189
- multiSelect: MultiSelectFieldComponent,
6190
- 'entity-ref': EntityRefFieldComponent,
6191
- group: GroupFieldComponent,
6192
- array: ArrayFieldComponent,
6193
- image: ImageFieldComponent,
6194
- file: FileFieldComponent,
6195
- };
6196
- }
6197
- /** Register a specific set of field types. Only the components you name are bundled. */
6198
- function provideFieldTypes(types) {
6199
- return makeEnvironmentProviders([
6200
- { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
6201
- ]);
6202
- }
6203
- /** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
6204
- function provideBuiltInFieldTypes() {
6205
- return provideFieldTypes(builtInFieldTypes());
6206
- }
6207
-
6208
- /**
6209
- * HookRegistryService — stores and runs Angular-side pre/post hooks.
6210
- * Consumers register hooks via provideNgxDynamicEntity({ hooks: { 'clients:beforeSave': fn } }).
6211
- */
6212
- class HookRegistryService {
6213
- registry = inject(HOOK_REGISTRY, { optional: true }) ?? new Map();
6214
- has(key) {
6215
- return this.registry.has(key);
6216
- }
6217
- async run(key, data, context = {}) {
6218
- const hook = this.registry.get(key);
6219
- if (!hook)
6220
- return data;
6221
- return hook(data, context);
6222
- }
6223
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6224
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, providedIn: 'root' });
6225
- }
6226
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, decorators: [{
6227
- type: Injectable,
6228
- args: [{ providedIn: 'root' }]
6229
- }] });
6230
-
6231
- /**
6232
- * RulesEvaluationService — evaluates rules, and owns what a result *means*.
6215
+ * URL field: a `type="url"` input, and a link when read-only.
6233
6216
  *
6234
- * Precedence lives in one method that both the render filter and the validity sync go
6235
- * through, so what is on screen and what counts toward validity cannot disagree. Stateless —
6236
- * every method takes the result it is interpreting, so there is nothing to go stale against a
6237
- * rebuilt form and nothing to reset between configs.
6217
+ * The read-only link is rendered only for a value that passes `isValidUrl`, meaning an http or
6218
+ * https address. A record can hold anything, including `javascript:`, and a field whose job is
6219
+ * to show an address must not turn a stored script into something to click. Anything else is
6220
+ * shown as plain text.
6238
6221
  */
6239
- class RulesEvaluationService {
6240
- /**
6241
- * Evaluate rules against form values and optional original baseline values.
6242
- */
6243
- evaluate(rules, values, originalValues, options) {
6244
- return evaluateFormRules(rules, values, originalValues, options);
6245
- }
6222
+ class UrlFieldComponent {
6246
6223
  /**
6247
- * Filter rules pertinent to a single tab.
6224
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6225
+ * DOM id may not repeat. See `field-dom-id.ts`.
6248
6226
  */
6249
- filterForTab(rules, tabId, config) {
6250
- return filterRulesForTab(rules, tabId, config);
6227
+ instanceId = nextFieldInstanceId();
6228
+ domId(suffix = '') {
6229
+ return fieldDomId(this.field, this.instanceId, suffix);
6251
6230
  }
6252
- /**
6253
- * Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
6254
- *
6255
- * An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
6256
- * `showWhen` that does not currently hold — because a rule that could only hide what the
6257
- * config already hides would have nothing to say, and `{ visibility: true }` was a
6258
- * documented no-op until it did. Otherwise the config's own visibility decides.
6259
- */
6260
- isFieldVisible(result, field, names, values) {
6261
- const sets = this.nameSets(result);
6262
- if (names.some(name => sets.hidden.has(name)))
6263
- return false;
6264
- if (names.some(name => sets.shown.has(name)))
6265
- return true;
6266
- return evaluateFieldVisibility(field, values);
6231
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6232
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6233
+ messages = inject(ValidationMessagesService);
6234
+ field;
6235
+ control;
6236
+ language = 'en';
6237
+ readonly = false;
6238
+ masked = false;
6239
+ get label() {
6240
+ return resolveLabel(this.field?.label, this.language);
6267
6241
  }
6268
- /** The same precedence for a tab. */
6269
- isTabVisible(result, tab) {
6270
- if (result.hiddenTabs.includes(tab.id))
6271
- return false;
6272
- return tab.visibility !== false || result.shownTabs.includes(tab.id);
6242
+ get placeholder() {
6243
+ return resolveLabel(this.field?.placeholder, this.language);
6273
6244
  }
6274
- /**
6275
- * Keep a hidden field's control out of form validity.
6276
- *
6277
- * A field hidden by a rule or a `showWhen` is filtered out of the render, but its
6278
- * validators stay attached — so a hidden *required* field holds `form.invalid` true
6279
- * forever, permanently disabling Save with nothing on screen to explain why.
6280
- *
6281
- * Disabling is the fix rather than stripping validators: Angular excludes disabled controls
6282
- * from validity, and the field's own validators survive intact for when it comes back.
6283
- * Values are untouched — a disabled control still carries its value, so rule evaluation and
6284
- * the emitted record see exactly what they saw before. That also keeps this from feeding
6285
- * back on itself: hiding a field cannot change the values the rules are evaluated over.
6286
- *
6287
- * The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
6288
- * screen and what counts toward validity cannot drift apart.
6289
- *
6290
- * Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
6291
- * children are skipped once the container itself is hidden, because Angular's `disable()`
6292
- * cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
6293
- * Toggling a child inside a hidden group would fight its parent.
6294
- */
6295
- syncHiddenFieldState(entries, ctx) {
6296
- const hiddenContainers = [];
6297
- for (const entry of entries) {
6298
- const field = entry.field;
6299
- if (!field?.id)
6300
- continue;
6301
- const address = ctx.addressOf(field);
6302
- if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
6303
- continue;
6304
- const hidden = !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
6305
- if (hidden && (field.type === 'group' || field.type === 'array'))
6306
- hiddenContainers.push(address);
6307
- const ctrl = ctx.controlFor(entry);
6308
- if (!ctrl)
6309
- continue;
6310
- if (hidden) {
6311
- if (ctrl.enabled)
6312
- ctrl.disable({ emitEvent: false });
6313
- }
6314
- else if (ctrl.disabled && !field.disabled) {
6315
- ctrl.enable({ emitEvent: false });
6316
- }
6317
- }
6245
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6246
+ get hint() {
6247
+ return resolveLabel(this.field?.hint, this.language);
6318
6248
  }
6319
- /**
6320
- * The hidden and shown names as sets, memoised against the result they came from.
6321
- *
6322
- * `isFieldVisible` runs once per field per change-detection pass; building two sets from
6323
- * two arrays each time turns a rule result into allocation proportional to the form. A
6324
- * `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
6325
- */
6326
- nameSets(result) {
6327
- let sets = this.nameSetCache.get(result);
6328
- if (!sets) {
6329
- sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
6330
- this.nameSetCache.set(result, sets);
6331
- }
6332
- return sets;
6249
+ /** Whether the stored value is an http(s) address, and so safe to render as a link. */
6250
+ get linkable() {
6251
+ const value = this.control?.value;
6252
+ return typeof value === 'string' && isValidUrl(value);
6333
6253
  }
6334
- nameSetCache = new WeakMap();
6335
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6336
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
6337
- }
6338
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, decorators: [{
6339
- type: Injectable,
6340
- args: [{ providedIn: 'root' }]
6341
- }] });
6342
-
6343
- class ConfigSourceService {
6344
- cache = new Map();
6345
- inFlight = new Map();
6346
- handler;
6347
- constructor(handler) {
6348
- this.handler = handler;
6254
+ /** The ids of whatever is describing this control right now. */
6255
+ describedBy() {
6256
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6349
6257
  }
6350
- /** Retrieve an entity config by key, with caching and in-flight deduplication. */
6351
- async getConfig(entityKey) {
6352
- if (!entityKey || !this.handler)
6353
- return undefined;
6354
- if (this.cache.has(entityKey))
6355
- return this.cache.get(entityKey);
6356
- if (this.inFlight.has(entityKey))
6357
- return this.inFlight.get(entityKey);
6358
- const promise = (async () => {
6359
- try {
6360
- const raw = this.handler(entityKey);
6361
- let resolved;
6362
- if (isObservable(raw)) {
6363
- resolved = await firstValueFrom(raw);
6364
- }
6365
- else {
6366
- resolved = await Promise.resolve(raw);
6367
- }
6368
- if (resolved) {
6369
- this.cache.set(entityKey, resolved);
6370
- }
6371
- return resolved;
6372
- }
6373
- catch (err) {
6374
- console.warn(`ConfigSourceService failed to load config for entity "${entityKey}"`, err);
6375
- return undefined;
6376
- }
6377
- finally {
6378
- this.inFlight.delete(entityKey);
6379
- }
6380
- })();
6381
- this.inFlight.set(entityKey, promise);
6382
- return promise;
6258
+ get errorMessage() {
6259
+ if (!this.control?.errors || !this.control.touched)
6260
+ return '';
6261
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6383
6262
  }
6384
- /** Clear cached configs (e.g. after a config edit in the builder). */
6385
- clearCache(entityKey) {
6386
- if (entityKey) {
6387
- this.cache.delete(entityKey);
6263
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: UrlFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6264
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: UrlFieldComponent, isStandalone: true, selector: "ngx-url-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6265
+ <div
6266
+ class="ngx-field ngx-field--url"
6267
+ [attr.data-testid]="'field-' + field.id"
6268
+ [attr.data-field-type]="field.type"
6269
+ [class.ngx-field--readonly]="readonly"
6270
+ [class.ngx-field--masked]="masked"
6271
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6272
+ >
6273
+ <div class="ngx-field__label-row">
6274
+ <label class="ngx-field__label" [attr.for]="domId()">
6275
+ {{ label }}
6276
+ @if (field.validators?.required) {
6277
+ <span class="ngx-field__req">*</span>
6278
+ }
6279
+ </label>
6280
+ @if (hint) {
6281
+ <span class="ngx-field__hint-wrap">
6282
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6283
+ <span
6284
+ class="ngx-field__hint"
6285
+ role="tooltip"
6286
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6287
+ [id]="domId('-hint')"
6288
+ >{{ hint }}</span
6289
+ >
6290
+ </span>
6291
+ }
6292
+ </div>
6293
+ @if (masked) {
6294
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6295
+ maskedText
6296
+ }}</span>
6297
+ } @else if (readonly) {
6298
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6299
+ @if (linkable) {
6300
+ <a [href]="control.value" class="ngx-field__link" target="_blank" rel="noopener noreferrer">{{
6301
+ control.value
6302
+ }}</a>
6303
+ } @else {
6304
+ {{ control.value || '—' }}
6305
+ }
6306
+ </span>
6307
+ } @else {
6308
+ <input
6309
+ [id]="domId()"
6310
+ class="ngx-field__input"
6311
+ [attr.data-testid]="'field-' + field.id + '-input'"
6312
+ type="url"
6313
+ inputmode="url"
6314
+ autocomplete="url"
6315
+ [formControl]="$any(control)"
6316
+ [attr.aria-invalid]="control.invalid && control.touched"
6317
+ [attr.aria-describedby]="describedBy()"
6318
+ [placeholder]="placeholder || 'https://example.com'"
6319
+ [attr.disabled]="field.disabled ? true : null"
6320
+ />
6321
+ @if (errorMessage) {
6322
+ <span
6323
+ class="ngx-field__error"
6324
+ [attr.data-testid]="'field-' + field.id + '-error'"
6325
+ [id]="domId('-error')"
6326
+ role="alert"
6327
+ >{{ errorMessage }}</span
6328
+ >
6329
+ }
6330
+ }
6331
+ </div>
6332
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6333
+ }
6334
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: UrlFieldComponent, decorators: [{
6335
+ type: Component,
6336
+ args: [{
6337
+ changeDetection: ChangeDetectionStrategy.OnPush,
6338
+ selector: 'ngx-url-field',
6339
+ standalone: true,
6340
+ imports: [ReactiveFormsModule],
6341
+ template: `
6342
+ <div
6343
+ class="ngx-field ngx-field--url"
6344
+ [attr.data-testid]="'field-' + field.id"
6345
+ [attr.data-field-type]="field.type"
6346
+ [class.ngx-field--readonly]="readonly"
6347
+ [class.ngx-field--masked]="masked"
6348
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6349
+ >
6350
+ <div class="ngx-field__label-row">
6351
+ <label class="ngx-field__label" [attr.for]="domId()">
6352
+ {{ label }}
6353
+ @if (field.validators?.required) {
6354
+ <span class="ngx-field__req">*</span>
6355
+ }
6356
+ </label>
6357
+ @if (hint) {
6358
+ <span class="ngx-field__hint-wrap">
6359
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6360
+ <span
6361
+ class="ngx-field__hint"
6362
+ role="tooltip"
6363
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6364
+ [id]="domId('-hint')"
6365
+ >{{ hint }}</span
6366
+ >
6367
+ </span>
6368
+ }
6369
+ </div>
6370
+ @if (masked) {
6371
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6372
+ maskedText
6373
+ }}</span>
6374
+ } @else if (readonly) {
6375
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6376
+ @if (linkable) {
6377
+ <a [href]="control.value" class="ngx-field__link" target="_blank" rel="noopener noreferrer">{{
6378
+ control.value
6379
+ }}</a>
6380
+ } @else {
6381
+ {{ control.value || '—' }}
6382
+ }
6383
+ </span>
6384
+ } @else {
6385
+ <input
6386
+ [id]="domId()"
6387
+ class="ngx-field__input"
6388
+ [attr.data-testid]="'field-' + field.id + '-input'"
6389
+ type="url"
6390
+ inputmode="url"
6391
+ autocomplete="url"
6392
+ [formControl]="$any(control)"
6393
+ [attr.aria-invalid]="control.invalid && control.touched"
6394
+ [attr.aria-describedby]="describedBy()"
6395
+ [placeholder]="placeholder || 'https://example.com'"
6396
+ [attr.disabled]="field.disabled ? true : null"
6397
+ />
6398
+ @if (errorMessage) {
6399
+ <span
6400
+ class="ngx-field__error"
6401
+ [attr.data-testid]="'field-' + field.id + '-error'"
6402
+ [id]="domId('-error')"
6403
+ role="alert"
6404
+ >{{ errorMessage }}</span
6405
+ >
6406
+ }
6407
+ }
6408
+ </div>
6409
+ `,
6410
+ }]
6411
+ }], propDecorators: { field: [{
6412
+ type: Input
6413
+ }], control: [{
6414
+ type: Input
6415
+ }], language: [{
6416
+ type: Input
6417
+ }], readonly: [{
6418
+ type: Input
6419
+ }], masked: [{
6420
+ type: Input
6421
+ }] } });
6422
+
6423
+ /**
6424
+ * Phone field: a `type="tel"` input, and a `tel:` link when read-only.
6425
+ *
6426
+ * The value is stored exactly as typed, spaces and brackets included. The number is shown back
6427
+ * to people far more often than it is dialled, and `+44 20 7946 0958` is easier to read than
6428
+ * its digits run together. Only the link target is stripped down to what a dialler needs.
6429
+ */
6430
+ class PhoneFieldComponent {
6431
+ /**
6432
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6433
+ * DOM id may not repeat. See `field-dom-id.ts`.
6434
+ */
6435
+ instanceId = nextFieldInstanceId();
6436
+ domId(suffix = '') {
6437
+ return fieldDomId(this.field, this.instanceId, suffix);
6438
+ }
6439
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6440
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6441
+ messages = inject(ValidationMessagesService);
6442
+ field;
6443
+ control;
6444
+ language = 'en';
6445
+ readonly = false;
6446
+ masked = false;
6447
+ get label() {
6448
+ return resolveLabel(this.field?.label, this.language);
6449
+ }
6450
+ get placeholder() {
6451
+ return resolveLabel(this.field?.placeholder, this.language);
6452
+ }
6453
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6454
+ get hint() {
6455
+ return resolveLabel(this.field?.hint, this.language);
6456
+ }
6457
+ /** `tel:` plus the digits and a leading `+`, or null for a value that is not a phone number. */
6458
+ get dialHref() {
6459
+ const value = this.control?.value;
6460
+ if (typeof value !== 'string' || !isValidPhone(value))
6461
+ return null;
6462
+ return `tel:${value.trim().startsWith('+') ? '+' : ''}${value.replace(/\D/g, '')}`;
6463
+ }
6464
+ /** The ids of whatever is describing this control right now. */
6465
+ describedBy() {
6466
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6467
+ }
6468
+ get errorMessage() {
6469
+ if (!this.control?.errors || !this.control.touched)
6470
+ return '';
6471
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6472
+ }
6473
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PhoneFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6474
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: PhoneFieldComponent, isStandalone: true, selector: "ngx-phone-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6475
+ <div
6476
+ class="ngx-field ngx-field--phone"
6477
+ [attr.data-testid]="'field-' + field.id"
6478
+ [attr.data-field-type]="field.type"
6479
+ [class.ngx-field--readonly]="readonly"
6480
+ [class.ngx-field--masked]="masked"
6481
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6482
+ >
6483
+ <div class="ngx-field__label-row">
6484
+ <label class="ngx-field__label" [attr.for]="domId()">
6485
+ {{ label }}
6486
+ @if (field.validators?.required) {
6487
+ <span class="ngx-field__req">*</span>
6488
+ }
6489
+ </label>
6490
+ @if (hint) {
6491
+ <span class="ngx-field__hint-wrap">
6492
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6493
+ <span
6494
+ class="ngx-field__hint"
6495
+ role="tooltip"
6496
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6497
+ [id]="domId('-hint')"
6498
+ >{{ hint }}</span
6499
+ >
6500
+ </span>
6501
+ }
6502
+ </div>
6503
+ @if (masked) {
6504
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6505
+ maskedText
6506
+ }}</span>
6507
+ } @else if (readonly) {
6508
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6509
+ @if (dialHref) {
6510
+ <a [href]="dialHref" class="ngx-field__link">{{ control.value }}</a>
6511
+ } @else {
6512
+ {{ control.value || '—' }}
6513
+ }
6514
+ </span>
6515
+ } @else {
6516
+ <input
6517
+ [id]="domId()"
6518
+ class="ngx-field__input"
6519
+ [attr.data-testid]="'field-' + field.id + '-input'"
6520
+ type="tel"
6521
+ inputmode="tel"
6522
+ autocomplete="tel"
6523
+ [formControl]="$any(control)"
6524
+ [attr.aria-invalid]="control.invalid && control.touched"
6525
+ [attr.aria-describedby]="describedBy()"
6526
+ [placeholder]="placeholder"
6527
+ [attr.disabled]="field.disabled ? true : null"
6528
+ />
6529
+ @if (errorMessage) {
6530
+ <span
6531
+ class="ngx-field__error"
6532
+ [attr.data-testid]="'field-' + field.id + '-error'"
6533
+ [id]="domId('-error')"
6534
+ role="alert"
6535
+ >{{ errorMessage }}</span
6536
+ >
6537
+ }
6538
+ }
6539
+ </div>
6540
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6541
+ }
6542
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PhoneFieldComponent, decorators: [{
6543
+ type: Component,
6544
+ args: [{
6545
+ changeDetection: ChangeDetectionStrategy.OnPush,
6546
+ selector: 'ngx-phone-field',
6547
+ standalone: true,
6548
+ imports: [ReactiveFormsModule],
6549
+ template: `
6550
+ <div
6551
+ class="ngx-field ngx-field--phone"
6552
+ [attr.data-testid]="'field-' + field.id"
6553
+ [attr.data-field-type]="field.type"
6554
+ [class.ngx-field--readonly]="readonly"
6555
+ [class.ngx-field--masked]="masked"
6556
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6557
+ >
6558
+ <div class="ngx-field__label-row">
6559
+ <label class="ngx-field__label" [attr.for]="domId()">
6560
+ {{ label }}
6561
+ @if (field.validators?.required) {
6562
+ <span class="ngx-field__req">*</span>
6563
+ }
6564
+ </label>
6565
+ @if (hint) {
6566
+ <span class="ngx-field__hint-wrap">
6567
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6568
+ <span
6569
+ class="ngx-field__hint"
6570
+ role="tooltip"
6571
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6572
+ [id]="domId('-hint')"
6573
+ >{{ hint }}</span
6574
+ >
6575
+ </span>
6576
+ }
6577
+ </div>
6578
+ @if (masked) {
6579
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6580
+ maskedText
6581
+ }}</span>
6582
+ } @else if (readonly) {
6583
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6584
+ @if (dialHref) {
6585
+ <a [href]="dialHref" class="ngx-field__link">{{ control.value }}</a>
6586
+ } @else {
6587
+ {{ control.value || '—' }}
6588
+ }
6589
+ </span>
6590
+ } @else {
6591
+ <input
6592
+ [id]="domId()"
6593
+ class="ngx-field__input"
6594
+ [attr.data-testid]="'field-' + field.id + '-input'"
6595
+ type="tel"
6596
+ inputmode="tel"
6597
+ autocomplete="tel"
6598
+ [formControl]="$any(control)"
6599
+ [attr.aria-invalid]="control.invalid && control.touched"
6600
+ [attr.aria-describedby]="describedBy()"
6601
+ [placeholder]="placeholder"
6602
+ [attr.disabled]="field.disabled ? true : null"
6603
+ />
6604
+ @if (errorMessage) {
6605
+ <span
6606
+ class="ngx-field__error"
6607
+ [attr.data-testid]="'field-' + field.id + '-error'"
6608
+ [id]="domId('-error')"
6609
+ role="alert"
6610
+ >{{ errorMessage }}</span
6611
+ >
6612
+ }
6613
+ }
6614
+ </div>
6615
+ `,
6616
+ }]
6617
+ }], propDecorators: { field: [{
6618
+ type: Input
6619
+ }], control: [{
6620
+ type: Input
6621
+ }], language: [{
6622
+ type: Input
6623
+ }], readonly: [{
6624
+ type: Input
6625
+ }], masked: [{
6626
+ type: Input
6627
+ }] } });
6628
+
6629
+ /**
6630
+ * Slider field: a range input over `validators.min`–`validators.max` in steps of `field.step`.
6631
+ *
6632
+ * A range input cannot show that it is empty. With no value it puts the thumb at the midpoint,
6633
+ * which looks exactly like a chosen value. The readout beside it is therefore the value itself,
6634
+ * and shows a dash until someone moves the thumb. Without it, a required slider nobody touched
6635
+ * would look answered and still fail at save.
6636
+ */
6637
+ class SliderFieldComponent {
6638
+ /**
6639
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6640
+ * DOM id may not repeat. See `field-dom-id.ts`.
6641
+ */
6642
+ instanceId = nextFieldInstanceId();
6643
+ domId(suffix = '') {
6644
+ return fieldDomId(this.field, this.instanceId, suffix);
6645
+ }
6646
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6647
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6648
+ messages = inject(ValidationMessagesService);
6649
+ field;
6650
+ control;
6651
+ language = 'en';
6652
+ readonly = false;
6653
+ masked = false;
6654
+ get label() {
6655
+ return resolveLabel(this.field?.label, this.language);
6656
+ }
6657
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6658
+ get hint() {
6659
+ return resolveLabel(this.field?.hint, this.language);
6660
+ }
6661
+ get bounds() {
6662
+ return sliderBounds(this.field);
6663
+ }
6664
+ /** Whether the control holds a number. The thumb's position cannot say. */
6665
+ get hasValue() {
6666
+ const value = this.control?.value;
6667
+ return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value));
6668
+ }
6669
+ get display() {
6670
+ return this.hasValue ? String(this.control.value) : '—';
6671
+ }
6672
+ /** The ids of whatever is describing this control right now. */
6673
+ describedBy() {
6674
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6675
+ }
6676
+ get errorMessage() {
6677
+ if (!this.control?.errors || !this.control.touched)
6678
+ return '';
6679
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6680
+ }
6681
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SliderFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6682
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SliderFieldComponent, isStandalone: true, selector: "ngx-slider-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6683
+ <div
6684
+ class="ngx-field ngx-field--slider"
6685
+ [attr.data-testid]="'field-' + field.id"
6686
+ [attr.data-field-type]="field.type"
6687
+ [class.ngx-field--readonly]="readonly"
6688
+ [class.ngx-field--masked]="masked"
6689
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6690
+ >
6691
+ <div class="ngx-field__label-row">
6692
+ <label class="ngx-field__label" [attr.for]="domId()">
6693
+ {{ label }}
6694
+ @if (field.validators?.required) {
6695
+ <span class="ngx-field__req">*</span>
6696
+ }
6697
+ </label>
6698
+ @if (hint) {
6699
+ <span class="ngx-field__hint-wrap">
6700
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6701
+ <span
6702
+ class="ngx-field__hint"
6703
+ role="tooltip"
6704
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6705
+ [id]="domId('-hint')"
6706
+ >{{ hint }}</span
6707
+ >
6708
+ </span>
6709
+ }
6710
+ </div>
6711
+ @if (masked) {
6712
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6713
+ maskedText
6714
+ }}</span>
6715
+ } @else if (readonly) {
6716
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ display }}</span>
6717
+ } @else {
6718
+ <div class="ngx-field__slider-wrap">
6719
+ <input
6720
+ [id]="domId()"
6721
+ type="range"
6722
+ class="ngx-field__slider"
6723
+ [class.ngx-field__slider--unset]="!hasValue"
6724
+ [attr.data-testid]="'field-' + field.id + '-input'"
6725
+ [min]="bounds.min"
6726
+ [max]="bounds.max"
6727
+ [step]="bounds.step"
6728
+ [formControl]="$any(control)"
6729
+ [attr.aria-valuetext]="hasValue ? null : display"
6730
+ [attr.aria-invalid]="control.invalid && control.touched"
6731
+ [attr.aria-describedby]="describedBy()"
6732
+ [attr.disabled]="field.disabled ? true : null"
6733
+ />
6734
+ <output
6735
+ class="ngx-field__slider-value"
6736
+ [attr.for]="domId()"
6737
+ [attr.data-testid]="'field-' + field.id + '-readout'"
6738
+ aria-hidden="true"
6739
+ >{{ display }}</output
6740
+ >
6741
+ </div>
6742
+ @if (errorMessage) {
6743
+ <span
6744
+ class="ngx-field__error"
6745
+ [attr.data-testid]="'field-' + field.id + '-error'"
6746
+ [id]="domId('-error')"
6747
+ role="alert"
6748
+ >{{ errorMessage }}</span
6749
+ >
6750
+ }
6751
+ }
6752
+ </div>
6753
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RangeValueAccessor, selector: "input[type=range][formControlName],input[type=range][formControl],input[type=range][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6754
+ }
6755
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SliderFieldComponent, decorators: [{
6756
+ type: Component,
6757
+ args: [{
6758
+ changeDetection: ChangeDetectionStrategy.OnPush,
6759
+ selector: 'ngx-slider-field',
6760
+ standalone: true,
6761
+ imports: [ReactiveFormsModule],
6762
+ template: `
6763
+ <div
6764
+ class="ngx-field ngx-field--slider"
6765
+ [attr.data-testid]="'field-' + field.id"
6766
+ [attr.data-field-type]="field.type"
6767
+ [class.ngx-field--readonly]="readonly"
6768
+ [class.ngx-field--masked]="masked"
6769
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6770
+ >
6771
+ <div class="ngx-field__label-row">
6772
+ <label class="ngx-field__label" [attr.for]="domId()">
6773
+ {{ label }}
6774
+ @if (field.validators?.required) {
6775
+ <span class="ngx-field__req">*</span>
6776
+ }
6777
+ </label>
6778
+ @if (hint) {
6779
+ <span class="ngx-field__hint-wrap">
6780
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6781
+ <span
6782
+ class="ngx-field__hint"
6783
+ role="tooltip"
6784
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6785
+ [id]="domId('-hint')"
6786
+ >{{ hint }}</span
6787
+ >
6788
+ </span>
6789
+ }
6790
+ </div>
6791
+ @if (masked) {
6792
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6793
+ maskedText
6794
+ }}</span>
6795
+ } @else if (readonly) {
6796
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ display }}</span>
6797
+ } @else {
6798
+ <div class="ngx-field__slider-wrap">
6799
+ <input
6800
+ [id]="domId()"
6801
+ type="range"
6802
+ class="ngx-field__slider"
6803
+ [class.ngx-field__slider--unset]="!hasValue"
6804
+ [attr.data-testid]="'field-' + field.id + '-input'"
6805
+ [min]="bounds.min"
6806
+ [max]="bounds.max"
6807
+ [step]="bounds.step"
6808
+ [formControl]="$any(control)"
6809
+ [attr.aria-valuetext]="hasValue ? null : display"
6810
+ [attr.aria-invalid]="control.invalid && control.touched"
6811
+ [attr.aria-describedby]="describedBy()"
6812
+ [attr.disabled]="field.disabled ? true : null"
6813
+ />
6814
+ <output
6815
+ class="ngx-field__slider-value"
6816
+ [attr.for]="domId()"
6817
+ [attr.data-testid]="'field-' + field.id + '-readout'"
6818
+ aria-hidden="true"
6819
+ >{{ display }}</output
6820
+ >
6821
+ </div>
6822
+ @if (errorMessage) {
6823
+ <span
6824
+ class="ngx-field__error"
6825
+ [attr.data-testid]="'field-' + field.id + '-error'"
6826
+ [id]="domId('-error')"
6827
+ role="alert"
6828
+ >{{ errorMessage }}</span
6829
+ >
6830
+ }
6831
+ }
6832
+ </div>
6833
+ `,
6834
+ }]
6835
+ }], propDecorators: { field: [{
6836
+ type: Input
6837
+ }], control: [{
6838
+ type: Input
6839
+ }], language: [{
6840
+ type: Input
6841
+ }], readonly: [{
6842
+ type: Input
6843
+ }], masked: [{
6844
+ type: Input
6845
+ }] } });
6846
+
6847
+ /**
6848
+ * Rating field: 1 to `validators.max` stars (default 5), stored as a number.
6849
+ *
6850
+ * Built from native radio inputs in a fieldset, not clickable spans. That gives it the
6851
+ * behaviour a radio group already has: arrow keys move the choice, Tab enters and leaves the
6852
+ * group once, and a screen reader announces "3 of 5, radio button, 3 of 5". Each star's
6853
+ * label is text a host can translate (`ratingValue`), because a star glyph read aloud is
6854
+ * "black star".
6855
+ */
6856
+ class RatingFieldComponent {
6857
+ /**
6858
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6859
+ * DOM id may not repeat. See `field-dom-id.ts`.
6860
+ */
6861
+ instanceId = nextFieldInstanceId();
6862
+ domId(suffix = '') {
6863
+ return fieldDomId(this.field, this.instanceId, suffix);
6864
+ }
6865
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6866
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6867
+ messages = inject(ValidationMessagesService);
6868
+ cdr = inject(ChangeDetectorRef);
6869
+ ui = inject(UiTextService);
6870
+ field;
6871
+ language = 'en';
6872
+ readonly = false;
6873
+ masked = false;
6874
+ _control;
6875
+ _controlSub;
6876
+ /**
6877
+ * Subscribed because the radios are not bound through a form directive. A value patched in
6878
+ * from outside (a reset, an autoPatch) would otherwise leave an OnPush view showing the old
6879
+ * stars.
6880
+ */
6881
+ set control(value) {
6882
+ this._control = value;
6883
+ this._controlSub?.unsubscribe();
6884
+ this._controlSub = this._control?.valueChanges?.subscribe(() => this.cdr.markForCheck());
6885
+ }
6886
+ get control() {
6887
+ return this._control;
6888
+ }
6889
+ ngOnDestroy() {
6890
+ this._controlSub?.unsubscribe();
6891
+ }
6892
+ get label() {
6893
+ return resolveLabel(this.field?.label, this.language);
6894
+ }
6895
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6896
+ get hint() {
6897
+ return resolveLabel(this.field?.hint, this.language);
6898
+ }
6899
+ get scale() {
6900
+ return ratingScale(this.field);
6901
+ }
6902
+ get stars() {
6903
+ return Array.from({ length: this.scale }, (_, i) => i + 1);
6904
+ }
6905
+ /** The stored rating, or null when there is none or it is not a star on this scale. */
6906
+ get current() {
6907
+ const value = Number(this.control?.value);
6908
+ const raw = this.control?.value;
6909
+ if (raw === null || raw === undefined || raw === '' || !Number.isInteger(value))
6910
+ return null;
6911
+ return value >= 1 && value <= this.scale ? value : null;
6912
+ }
6913
+ select(star) {
6914
+ this.control?.setValue(star);
6915
+ this.control?.markAsTouched();
6916
+ this.control?.markAsDirty();
6917
+ }
6918
+ clear() {
6919
+ this.control?.setValue(null);
6920
+ this.control?.markAsTouched();
6921
+ this.control?.markAsDirty();
6922
+ }
6923
+ /** The ids of whatever is describing this control right now. */
6924
+ describedBy() {
6925
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6926
+ }
6927
+ get errorMessage() {
6928
+ if (!this.control?.errors || !this.control.touched)
6929
+ return '';
6930
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6931
+ }
6932
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RatingFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6933
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: RatingFieldComponent, isStandalone: true, selector: "ngx-rating-field", inputs: { field: "field", language: "language", readonly: "readonly", masked: "masked", control: "control" }, ngImport: i0, template: `
6934
+ <div
6935
+ class="ngx-field ngx-field--rating"
6936
+ [attr.data-testid]="'field-' + field.id"
6937
+ [attr.data-field-type]="field.type"
6938
+ [class.ngx-field--readonly]="readonly"
6939
+ [class.ngx-field--masked]="masked"
6940
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6941
+ >
6942
+ <fieldset class="ngx-field__fieldset">
6943
+ <legend class="ngx-field__label">
6944
+ {{ label }}
6945
+ @if (field.validators?.required) {
6946
+ <span class="ngx-field__req">*</span>
6947
+ }
6948
+ @if (hint) {
6949
+ <!-- Inside the legend: a fieldset allows one legend, and it must come first. -->
6950
+ <span class="ngx-field__hint-wrap">
6951
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6952
+ <span
6953
+ class="ngx-field__hint"
6954
+ role="tooltip"
6955
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6956
+ [id]="domId('-hint')"
6957
+ >{{ hint }}</span
6958
+ >
6959
+ </span>
6960
+ }
6961
+ </legend>
6962
+ @if (masked) {
6963
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6964
+ maskedText
6965
+ }}</span>
6966
+ } @else if (readonly) {
6967
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6968
+ @if (current) {
6969
+ <span class="ngx-field__stars" aria-hidden="true">
6970
+ @for (star of stars; track star) {
6971
+ <span class="ngx-field__star-glyph" [class.ngx-field__star-glyph--on]="star <= current">{{
6972
+ star <= current ? '★' : '☆'
6973
+ }}</span>
6974
+ }
6975
+ </span>
6976
+ {{ ui.text('ratingValue', language, { value: current, max: scale }) }}
6977
+ } @else {
6978
+ —
6979
+ }
6980
+ </span>
6981
+ } @else {
6982
+ <div class="ngx-field__rating">
6983
+ @for (star of stars; track star) {
6984
+ <label class="ngx-field__star" [class.ngx-field__star--on]="current !== null && star <= current">
6985
+ <input
6986
+ type="radio"
6987
+ class="ngx-field__star-input"
6988
+ [id]="domId('-' + star)"
6989
+ [name]="domId()"
6990
+ [value]="star"
6991
+ [checked]="current === star"
6992
+ [attr.data-testid]="'field-' + field.id + '-star-' + star"
6993
+ [attr.aria-describedby]="describedBy()"
6994
+ [attr.disabled]="field.disabled ? true : null"
6995
+ (change)="select(star)"
6996
+ />
6997
+ <!-- Filled and hollow, so the rating is readable without telling colours apart. -->
6998
+ <span class="ngx-field__star-glyph" aria-hidden="true">{{
6999
+ current !== null && star <= current ? '★' : '☆'
7000
+ }}</span>
7001
+ <span class="ngx-field__sr-only">{{ ui.text('ratingValue', language, { value: star, max: scale }) }}</span>
7002
+ </label>
7003
+ }
7004
+ @if (current !== null && !field.disabled) {
7005
+ <button
7006
+ type="button"
7007
+ class="ngx-field__remove-btn"
7008
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7009
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7010
+ (click)="clear()"
7011
+ >
7012
+ ×
7013
+ </button>
7014
+ }
7015
+ </div>
7016
+ @if (errorMessage) {
7017
+ <span
7018
+ class="ngx-field__error"
7019
+ [attr.data-testid]="'field-' + field.id + '-error'"
7020
+ [id]="domId('-error')"
7021
+ role="alert"
7022
+ >{{ errorMessage }}</span
7023
+ >
7024
+ }
7025
+ }
7026
+ </fieldset>
7027
+ </div>
7028
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7029
+ }
7030
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RatingFieldComponent, decorators: [{
7031
+ type: Component,
7032
+ args: [{
7033
+ changeDetection: ChangeDetectionStrategy.OnPush,
7034
+ selector: 'ngx-rating-field',
7035
+ standalone: true,
7036
+ imports: [ReactiveFormsModule],
7037
+ template: `
7038
+ <div
7039
+ class="ngx-field ngx-field--rating"
7040
+ [attr.data-testid]="'field-' + field.id"
7041
+ [attr.data-field-type]="field.type"
7042
+ [class.ngx-field--readonly]="readonly"
7043
+ [class.ngx-field--masked]="masked"
7044
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7045
+ >
7046
+ <fieldset class="ngx-field__fieldset">
7047
+ <legend class="ngx-field__label">
7048
+ {{ label }}
7049
+ @if (field.validators?.required) {
7050
+ <span class="ngx-field__req">*</span>
7051
+ }
7052
+ @if (hint) {
7053
+ <!-- Inside the legend: a fieldset allows one legend, and it must come first. -->
7054
+ <span class="ngx-field__hint-wrap">
7055
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7056
+ <span
7057
+ class="ngx-field__hint"
7058
+ role="tooltip"
7059
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7060
+ [id]="domId('-hint')"
7061
+ >{{ hint }}</span
7062
+ >
7063
+ </span>
7064
+ }
7065
+ </legend>
7066
+ @if (masked) {
7067
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7068
+ maskedText
7069
+ }}</span>
7070
+ } @else if (readonly) {
7071
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7072
+ @if (current) {
7073
+ <span class="ngx-field__stars" aria-hidden="true">
7074
+ @for (star of stars; track star) {
7075
+ <span class="ngx-field__star-glyph" [class.ngx-field__star-glyph--on]="star <= current">{{
7076
+ star <= current ? '★' : '☆'
7077
+ }}</span>
7078
+ }
7079
+ </span>
7080
+ {{ ui.text('ratingValue', language, { value: current, max: scale }) }}
7081
+ } @else {
7082
+ —
7083
+ }
7084
+ </span>
7085
+ } @else {
7086
+ <div class="ngx-field__rating">
7087
+ @for (star of stars; track star) {
7088
+ <label class="ngx-field__star" [class.ngx-field__star--on]="current !== null && star <= current">
7089
+ <input
7090
+ type="radio"
7091
+ class="ngx-field__star-input"
7092
+ [id]="domId('-' + star)"
7093
+ [name]="domId()"
7094
+ [value]="star"
7095
+ [checked]="current === star"
7096
+ [attr.data-testid]="'field-' + field.id + '-star-' + star"
7097
+ [attr.aria-describedby]="describedBy()"
7098
+ [attr.disabled]="field.disabled ? true : null"
7099
+ (change)="select(star)"
7100
+ />
7101
+ <!-- Filled and hollow, so the rating is readable without telling colours apart. -->
7102
+ <span class="ngx-field__star-glyph" aria-hidden="true">{{
7103
+ current !== null && star <= current ? '★' : '☆'
7104
+ }}</span>
7105
+ <span class="ngx-field__sr-only">{{ ui.text('ratingValue', language, { value: star, max: scale }) }}</span>
7106
+ </label>
7107
+ }
7108
+ @if (current !== null && !field.disabled) {
7109
+ <button
7110
+ type="button"
7111
+ class="ngx-field__remove-btn"
7112
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7113
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7114
+ (click)="clear()"
7115
+ >
7116
+ ×
7117
+ </button>
7118
+ }
7119
+ </div>
7120
+ @if (errorMessage) {
7121
+ <span
7122
+ class="ngx-field__error"
7123
+ [attr.data-testid]="'field-' + field.id + '-error'"
7124
+ [id]="domId('-error')"
7125
+ role="alert"
7126
+ >{{ errorMessage }}</span
7127
+ >
7128
+ }
7129
+ }
7130
+ </fieldset>
7131
+ </div>
7132
+ `,
7133
+ }]
7134
+ }], propDecorators: { field: [{
7135
+ type: Input
7136
+ }], language: [{
7137
+ type: Input
7138
+ }], readonly: [{
7139
+ type: Input
7140
+ }], masked: [{
7141
+ type: Input
7142
+ }], control: [{
7143
+ type: Input
7144
+ }] } });
7145
+
7146
+ /**
7147
+ * Colour field: a native colour picker, storing `#rrggbb`.
7148
+ *
7149
+ * Like a range input, a colour input has no empty state and shows black. The code beside it is
7150
+ * therefore the real value, a dash until a colour is chosen, and a clear button sets the value
7151
+ * back to nothing. Without them, "no colour" and "black" would look the same.
7152
+ *
7153
+ * Only a value that passes `normalizeHexColor` reaches a `style` binding. A record can hold
7154
+ * any string, and a colour swatch is no place to interpret one.
7155
+ */
7156
+ class ColorFieldComponent {
7157
+ /**
7158
+ * Unique to this component instance: an `array` renders the same field once per row, and a
7159
+ * DOM id may not repeat. See `field-dom-id.ts`.
7160
+ */
7161
+ instanceId = nextFieldInstanceId();
7162
+ domId(suffix = '') {
7163
+ return fieldDomId(this.field, this.instanceId, suffix);
7164
+ }
7165
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
7166
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
7167
+ messages = inject(ValidationMessagesService);
7168
+ ui = inject(UiTextService);
7169
+ field;
7170
+ control;
7171
+ language = 'en';
7172
+ readonly = false;
7173
+ masked = false;
7174
+ get label() {
7175
+ return resolveLabel(this.field?.label, this.language);
7176
+ }
7177
+ /** Author help text, shown under the control and named by `aria-describedby`. */
7178
+ get hint() {
7179
+ return resolveLabel(this.field?.hint, this.language);
7180
+ }
7181
+ /** The stored colour as `#rrggbb`, or null when there is none or it is not a colour. */
7182
+ get hex() {
7183
+ return normalizeHexColor(this.control?.value);
7184
+ }
7185
+ clear() {
7186
+ this.control?.setValue(null);
7187
+ this.control?.markAsTouched();
7188
+ this.control?.markAsDirty();
7189
+ }
7190
+ /** The ids of whatever is describing this control right now. */
7191
+ describedBy() {
7192
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
7193
+ }
7194
+ get errorMessage() {
7195
+ if (!this.control?.errors || !this.control.touched)
7196
+ return '';
7197
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
7198
+ }
7199
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ColorFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7200
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: ColorFieldComponent, isStandalone: true, selector: "ngx-color-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
7201
+ <div
7202
+ class="ngx-field ngx-field--color"
7203
+ [attr.data-testid]="'field-' + field.id"
7204
+ [attr.data-field-type]="field.type"
7205
+ [class.ngx-field--readonly]="readonly"
7206
+ [class.ngx-field--masked]="masked"
7207
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7208
+ >
7209
+ <div class="ngx-field__label-row">
7210
+ <label class="ngx-field__label" [attr.for]="domId()">
7211
+ {{ label }}
7212
+ @if (field.validators?.required) {
7213
+ <span class="ngx-field__req">*</span>
7214
+ }
7215
+ </label>
7216
+ @if (hint) {
7217
+ <span class="ngx-field__hint-wrap">
7218
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7219
+ <span
7220
+ class="ngx-field__hint"
7221
+ role="tooltip"
7222
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7223
+ [id]="domId('-hint')"
7224
+ >{{ hint }}</span
7225
+ >
7226
+ </span>
7227
+ }
7228
+ </div>
7229
+ @if (masked) {
7230
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7231
+ maskedText
7232
+ }}</span>
7233
+ } @else if (readonly) {
7234
+ <span class="ngx-field__value ngx-field__color-value" [attr.data-testid]="'field-' + field.id + '-value'">
7235
+ @if (hex) {
7236
+ <span class="ngx-field__swatch" [style.background-color]="hex" aria-hidden="true"></span>
7237
+ {{ hex }}
7238
+ } @else {
7239
+ {{ control.value || '—' }}
7240
+ }
7241
+ </span>
7242
+ } @else {
7243
+ <div class="ngx-field__color-wrap">
7244
+ <input
7245
+ [id]="domId()"
7246
+ type="color"
7247
+ class="ngx-field__color"
7248
+ [attr.data-testid]="'field-' + field.id + '-input'"
7249
+ [formControl]="$any(control)"
7250
+ [attr.aria-invalid]="control.invalid && control.touched"
7251
+ [attr.aria-describedby]="describedBy()"
7252
+ [attr.disabled]="field.disabled ? true : null"
7253
+ />
7254
+ <span class="ngx-field__color-code" [attr.data-testid]="'field-' + field.id + '-code'">{{ hex || '—' }}</span>
7255
+ @if (hex && !field.disabled) {
7256
+ <button
7257
+ type="button"
7258
+ class="ngx-field__remove-btn"
7259
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7260
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7261
+ (click)="clear()"
7262
+ >
7263
+ ×
7264
+ </button>
7265
+ }
7266
+ </div>
7267
+ @if (errorMessage) {
7268
+ <span
7269
+ class="ngx-field__error"
7270
+ [attr.data-testid]="'field-' + field.id + '-error'"
7271
+ [id]="domId('-error')"
7272
+ role="alert"
7273
+ >{{ errorMessage }}</span
7274
+ >
7275
+ }
7276
+ }
7277
+ </div>
7278
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7279
+ }
7280
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ColorFieldComponent, decorators: [{
7281
+ type: Component,
7282
+ args: [{
7283
+ changeDetection: ChangeDetectionStrategy.OnPush,
7284
+ selector: 'ngx-color-field',
7285
+ standalone: true,
7286
+ imports: [ReactiveFormsModule],
7287
+ template: `
7288
+ <div
7289
+ class="ngx-field ngx-field--color"
7290
+ [attr.data-testid]="'field-' + field.id"
7291
+ [attr.data-field-type]="field.type"
7292
+ [class.ngx-field--readonly]="readonly"
7293
+ [class.ngx-field--masked]="masked"
7294
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7295
+ >
7296
+ <div class="ngx-field__label-row">
7297
+ <label class="ngx-field__label" [attr.for]="domId()">
7298
+ {{ label }}
7299
+ @if (field.validators?.required) {
7300
+ <span class="ngx-field__req">*</span>
7301
+ }
7302
+ </label>
7303
+ @if (hint) {
7304
+ <span class="ngx-field__hint-wrap">
7305
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7306
+ <span
7307
+ class="ngx-field__hint"
7308
+ role="tooltip"
7309
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7310
+ [id]="domId('-hint')"
7311
+ >{{ hint }}</span
7312
+ >
7313
+ </span>
7314
+ }
7315
+ </div>
7316
+ @if (masked) {
7317
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7318
+ maskedText
7319
+ }}</span>
7320
+ } @else if (readonly) {
7321
+ <span class="ngx-field__value ngx-field__color-value" [attr.data-testid]="'field-' + field.id + '-value'">
7322
+ @if (hex) {
7323
+ <span class="ngx-field__swatch" [style.background-color]="hex" aria-hidden="true"></span>
7324
+ {{ hex }}
7325
+ } @else {
7326
+ {{ control.value || '—' }}
7327
+ }
7328
+ </span>
7329
+ } @else {
7330
+ <div class="ngx-field__color-wrap">
7331
+ <input
7332
+ [id]="domId()"
7333
+ type="color"
7334
+ class="ngx-field__color"
7335
+ [attr.data-testid]="'field-' + field.id + '-input'"
7336
+ [formControl]="$any(control)"
7337
+ [attr.aria-invalid]="control.invalid && control.touched"
7338
+ [attr.aria-describedby]="describedBy()"
7339
+ [attr.disabled]="field.disabled ? true : null"
7340
+ />
7341
+ <span class="ngx-field__color-code" [attr.data-testid]="'field-' + field.id + '-code'">{{ hex || '—' }}</span>
7342
+ @if (hex && !field.disabled) {
7343
+ <button
7344
+ type="button"
7345
+ class="ngx-field__remove-btn"
7346
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7347
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7348
+ (click)="clear()"
7349
+ >
7350
+ ×
7351
+ </button>
7352
+ }
7353
+ </div>
7354
+ @if (errorMessage) {
7355
+ <span
7356
+ class="ngx-field__error"
7357
+ [attr.data-testid]="'field-' + field.id + '-error'"
7358
+ [id]="domId('-error')"
7359
+ role="alert"
7360
+ >{{ errorMessage }}</span
7361
+ >
7362
+ }
7363
+ }
7364
+ </div>
7365
+ `,
7366
+ }]
7367
+ }], propDecorators: { field: [{
7368
+ type: Input
7369
+ }], control: [{
7370
+ type: Input
7371
+ }], language: [{
7372
+ type: Input
7373
+ }], readonly: [{
7374
+ type: Input
7375
+ }], masked: [{
7376
+ type: Input
7377
+ }] } });
7378
+
7379
+ /** What ends a tag while typing, or separates several in pasted text. */
7380
+ const TAG_SEPARATOR = /[,;\n]/;
7381
+ /**
7382
+ * Tags field: free text entered as a list of chips, stored as `string[]`.
7383
+ *
7384
+ * Unlike `multiSelect`, the values are whatever the user types, so there is no option list to
7385
+ * keep in step. Enter or a comma ends a tag, Backspace in an empty box removes the last one,
7386
+ * and pasted text is split on commas, semicolons and line breaks. Leaving the box commits
7387
+ * what is in it, so a tag someone typed and then tabbed away from is not silently lost.
7388
+ *
7389
+ * The control holds the array. The text box is only a draft, which is why it is not bound
7390
+ * with a form directive.
7391
+ */
7392
+ class TagsFieldComponent {
7393
+ /**
7394
+ * Unique to this component instance: an `array` renders the same field once per row, and a
7395
+ * DOM id may not repeat. See `field-dom-id.ts`.
7396
+ */
7397
+ instanceId = nextFieldInstanceId();
7398
+ domId(suffix = '') {
7399
+ return fieldDomId(this.field, this.instanceId, suffix);
7400
+ }
7401
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
7402
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
7403
+ messages = inject(ValidationMessagesService);
7404
+ cdr = inject(ChangeDetectorRef);
7405
+ ui = inject(UiTextService);
7406
+ field;
7407
+ language = 'en';
7408
+ readonly = false;
7409
+ masked = false;
7410
+ _control;
7411
+ _controlSub;
7412
+ /** Subscribed for the same reason as the rating field: nothing here is a form directive. */
7413
+ set control(value) {
7414
+ this._control = value;
7415
+ this._controlSub?.unsubscribe();
7416
+ this._controlSub = this._control?.valueChanges?.subscribe(() => this.cdr.markForCheck());
7417
+ }
7418
+ get control() {
7419
+ return this._control;
7420
+ }
7421
+ ngOnDestroy() {
7422
+ this._controlSub?.unsubscribe();
7423
+ }
7424
+ get label() {
7425
+ return resolveLabel(this.field?.label, this.language);
7426
+ }
7427
+ get placeholder() {
7428
+ return resolveLabel(this.field?.placeholder, this.language);
7429
+ }
7430
+ /** Author help text, shown under the control and named by `aria-describedby`. */
7431
+ get hint() {
7432
+ return resolveLabel(this.field?.hint, this.language);
7433
+ }
7434
+ /** The stored tags, read leniently. A record may hold a single string or anything else. */
7435
+ get tags() {
7436
+ return normalizeTags(this.control?.value);
7437
+ }
7438
+ onKeydown(event, entry) {
7439
+ if (event.key === 'Enter' || event.key === ',' || event.key === ';') {
7440
+ // Enter inside a form submits it. Adding a tag is what the user meant.
7441
+ event.preventDefault();
7442
+ this.commit(entry);
7443
+ }
7444
+ else if (event.key === 'Backspace' && entry.value === '' && this.tags.length) {
7445
+ this.remove(this.tags[this.tags.length - 1]);
7446
+ }
7447
+ }
7448
+ onPaste(event, entry) {
7449
+ const text = event.clipboardData?.getData('text') ?? '';
7450
+ if (!TAG_SEPARATOR.test(text))
7451
+ return;
7452
+ event.preventDefault();
7453
+ // Split here, not by writing the text into the box first: a single-line input strips line
7454
+ // breaks on assignment, which would join "z\nw" into one tag.
7455
+ const draft = entry.value;
7456
+ entry.value = '';
7457
+ this.add([draft, ...text.split(TAG_SEPARATOR)]);
7458
+ }
7459
+ onBlur(entry) {
7460
+ this.commit(entry);
7461
+ this.control?.markAsTouched();
7462
+ }
7463
+ /** Add whatever is in the draft box, split on separators, and empty it. */
7464
+ commit(entry) {
7465
+ const added = entry.value.split(TAG_SEPARATOR);
7466
+ entry.value = '';
7467
+ this.add(added);
7468
+ }
7469
+ add(candidates) {
7470
+ const next = normalizeTags([...this.tags, ...candidates]);
7471
+ if (next.length !== this.tags.length)
7472
+ this.write(next);
7473
+ }
7474
+ remove(tag) {
7475
+ this.write(this.tags.filter(t => t !== tag));
7476
+ }
7477
+ /**
7478
+ * An empty list is stored as `[]`, not `null`. `required` treats both as empty, and `[]`
7479
+ * stays the same shape as every other value this field holds.
7480
+ */
7481
+ write(tags) {
7482
+ this.control?.setValue(tags);
7483
+ this.control?.markAsTouched();
7484
+ this.control?.markAsDirty();
7485
+ }
7486
+ /** The ids of whatever is describing this control right now. */
7487
+ describedBy() {
7488
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
7489
+ }
7490
+ get errorMessage() {
7491
+ if (!this.control?.errors || !this.control.touched)
7492
+ return '';
7493
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
7494
+ }
7495
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: TagsFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7496
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: TagsFieldComponent, isStandalone: true, selector: "ngx-tags-field", inputs: { field: "field", language: "language", readonly: "readonly", masked: "masked", control: "control" }, ngImport: i0, template: `
7497
+ <div
7498
+ class="ngx-field ngx-field--tags"
7499
+ [attr.data-testid]="'field-' + field.id"
7500
+ [attr.data-field-type]="field.type"
7501
+ [class.ngx-field--readonly]="readonly"
7502
+ [class.ngx-field--masked]="masked"
7503
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7504
+ >
7505
+ <div class="ngx-field__label-row">
7506
+ <label class="ngx-field__label" [attr.for]="domId()">
7507
+ {{ label }}
7508
+ @if (field.validators?.required) {
7509
+ <span class="ngx-field__req">*</span>
7510
+ }
7511
+ </label>
7512
+ @if (hint) {
7513
+ <span class="ngx-field__hint-wrap">
7514
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7515
+ <span
7516
+ class="ngx-field__hint"
7517
+ role="tooltip"
7518
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7519
+ [id]="domId('-hint')"
7520
+ >{{ hint }}</span
7521
+ >
7522
+ </span>
7523
+ }
7524
+ </div>
7525
+ @if (masked) {
7526
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7527
+ maskedText
7528
+ }}</span>
7529
+ } @else if (readonly) {
7530
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7531
+ @if (tags.length) {
7532
+ <span class="ngx-field__tags ngx-field__tags--readonly">
7533
+ @for (tag of tags; track tag) {
7534
+ <span class="ngx-field__tag">{{ tag }}</span>
7535
+ }
7536
+ </span>
7537
+ } @else {
7538
+ —
7539
+ }
7540
+ </span>
7541
+ } @else {
7542
+ <div class="ngx-field__tags" [class.ngx-field__tags--disabled]="field.disabled">
7543
+ @for (tag of tags; track tag) {
7544
+ <span class="ngx-field__tag" [attr.data-testid]="'field-' + field.id + '-tag'">
7545
+ {{ tag }}
7546
+ @if (!field.disabled) {
7547
+ <button
7548
+ type="button"
7549
+ class="ngx-field__tag-remove"
7550
+ [attr.aria-label]="ui.text('removeTag', language, { tag: tag })"
7551
+ (click)="remove(tag)"
7552
+ >
7553
+ ×
7554
+ </button>
7555
+ }
7556
+ </span>
7557
+ }
7558
+ <input
7559
+ #entry
7560
+ [id]="domId()"
7561
+ type="text"
7562
+ class="ngx-field__tags-input"
7563
+ [attr.data-testid]="'field-' + field.id + '-input'"
7564
+ [attr.aria-invalid]="control.invalid && control.touched"
7565
+ [attr.aria-describedby]="describedBy()"
7566
+ [placeholder]="placeholder || ui.text('addTagPlaceholder', language)"
7567
+ [attr.disabled]="field.disabled ? true : null"
7568
+ (keydown)="onKeydown($event, entry)"
7569
+ (paste)="onPaste($event, entry)"
7570
+ (blur)="onBlur(entry)"
7571
+ />
7572
+ </div>
7573
+ @if (errorMessage) {
7574
+ <span
7575
+ class="ngx-field__error"
7576
+ [attr.data-testid]="'field-' + field.id + '-error'"
7577
+ [id]="domId('-error')"
7578
+ role="alert"
7579
+ >{{ errorMessage }}</span
7580
+ >
7581
+ }
7582
+ }
7583
+ </div>
7584
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
7585
+ }
7586
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: TagsFieldComponent, decorators: [{
7587
+ type: Component,
7588
+ args: [{
7589
+ changeDetection: ChangeDetectionStrategy.OnPush,
7590
+ selector: 'ngx-tags-field',
7591
+ standalone: true,
7592
+ template: `
7593
+ <div
7594
+ class="ngx-field ngx-field--tags"
7595
+ [attr.data-testid]="'field-' + field.id"
7596
+ [attr.data-field-type]="field.type"
7597
+ [class.ngx-field--readonly]="readonly"
7598
+ [class.ngx-field--masked]="masked"
7599
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7600
+ >
7601
+ <div class="ngx-field__label-row">
7602
+ <label class="ngx-field__label" [attr.for]="domId()">
7603
+ {{ label }}
7604
+ @if (field.validators?.required) {
7605
+ <span class="ngx-field__req">*</span>
7606
+ }
7607
+ </label>
7608
+ @if (hint) {
7609
+ <span class="ngx-field__hint-wrap">
7610
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7611
+ <span
7612
+ class="ngx-field__hint"
7613
+ role="tooltip"
7614
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7615
+ [id]="domId('-hint')"
7616
+ >{{ hint }}</span
7617
+ >
7618
+ </span>
7619
+ }
7620
+ </div>
7621
+ @if (masked) {
7622
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7623
+ maskedText
7624
+ }}</span>
7625
+ } @else if (readonly) {
7626
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7627
+ @if (tags.length) {
7628
+ <span class="ngx-field__tags ngx-field__tags--readonly">
7629
+ @for (tag of tags; track tag) {
7630
+ <span class="ngx-field__tag">{{ tag }}</span>
7631
+ }
7632
+ </span>
7633
+ } @else {
7634
+ —
7635
+ }
7636
+ </span>
7637
+ } @else {
7638
+ <div class="ngx-field__tags" [class.ngx-field__tags--disabled]="field.disabled">
7639
+ @for (tag of tags; track tag) {
7640
+ <span class="ngx-field__tag" [attr.data-testid]="'field-' + field.id + '-tag'">
7641
+ {{ tag }}
7642
+ @if (!field.disabled) {
7643
+ <button
7644
+ type="button"
7645
+ class="ngx-field__tag-remove"
7646
+ [attr.aria-label]="ui.text('removeTag', language, { tag: tag })"
7647
+ (click)="remove(tag)"
7648
+ >
7649
+ ×
7650
+ </button>
7651
+ }
7652
+ </span>
7653
+ }
7654
+ <input
7655
+ #entry
7656
+ [id]="domId()"
7657
+ type="text"
7658
+ class="ngx-field__tags-input"
7659
+ [attr.data-testid]="'field-' + field.id + '-input'"
7660
+ [attr.aria-invalid]="control.invalid && control.touched"
7661
+ [attr.aria-describedby]="describedBy()"
7662
+ [placeholder]="placeholder || ui.text('addTagPlaceholder', language)"
7663
+ [attr.disabled]="field.disabled ? true : null"
7664
+ (keydown)="onKeydown($event, entry)"
7665
+ (paste)="onPaste($event, entry)"
7666
+ (blur)="onBlur(entry)"
7667
+ />
7668
+ </div>
7669
+ @if (errorMessage) {
7670
+ <span
7671
+ class="ngx-field__error"
7672
+ [attr.data-testid]="'field-' + field.id + '-error'"
7673
+ [id]="domId('-error')"
7674
+ role="alert"
7675
+ >{{ errorMessage }}</span
7676
+ >
7677
+ }
7678
+ }
7679
+ </div>
7680
+ `,
7681
+ }]
7682
+ }], propDecorators: { field: [{
7683
+ type: Input
7684
+ }], language: [{
7685
+ type: Input
7686
+ }], readonly: [{
7687
+ type: Input
7688
+ }], masked: [{
7689
+ type: Input
7690
+ }], control: [{
7691
+ type: Input
7692
+ }] } });
7693
+
7694
+ /**
7695
+ * Field-type registration — the tree-shaking seam.
7696
+ *
7697
+ * `FieldRegistryService` imports no components. Whatever you register here is what gets
7698
+ * bundled, so an app that only uses text/number/dropdown pays for three components and
7699
+ * their imports, not twenty-seven.
7700
+ *
7701
+ * ```ts
7702
+ * // Everything (convenient; bundles all built-ins):
7703
+ * provideNgxDynamicEntity(), provideBuiltInFieldTypes()
7704
+ *
7705
+ * // Only what this app renders:
7706
+ * provideFieldTypes({ text: TextFieldComponent, number: NumberFieldComponent })
7707
+ * ```
7708
+ *
7709
+ * Both merge into `FIELD_TYPE_REGISTRY`; later registrations win on key collision.
7710
+ */
7711
+ /** Map of every built-in field type key → component. Referencing this pulls in all of them. */
7712
+ function builtInFieldTypes() {
7713
+ return {
7714
+ text: TextFieldComponent,
7715
+ textarea: TextareaFieldComponent,
7716
+ markdown: MarkdownFieldComponent,
7717
+ number: NumberFieldComponent,
7718
+ currency: CurrencyFieldComponent,
7719
+ email: EmailFieldComponent,
7720
+ password: PasswordFieldComponent,
7721
+ checkbox: CheckboxFieldComponent,
7722
+ boolean: BooleanFieldComponent,
7723
+ date: DateFieldComponent,
7724
+ datetime: DateTimeFieldComponent,
7725
+ time: TimeFieldComponent,
7726
+ monthYear: MonthYearFieldComponent,
7727
+ dropdown: DropdownFieldComponent,
7728
+ radio: RadioFieldComponent,
7729
+ multiSelect: MultiSelectFieldComponent,
7730
+ 'entity-ref': EntityRefFieldComponent,
7731
+ group: GroupFieldComponent,
7732
+ array: ArrayFieldComponent,
7733
+ image: ImageFieldComponent,
7734
+ file: FileFieldComponent,
7735
+ url: UrlFieldComponent,
7736
+ phone: PhoneFieldComponent,
7737
+ slider: SliderFieldComponent,
7738
+ rating: RatingFieldComponent,
7739
+ color: ColorFieldComponent,
7740
+ tags: TagsFieldComponent,
7741
+ };
7742
+ }
7743
+ /** Register a specific set of field types. Only the components you name are bundled. */
7744
+ function provideFieldTypes(types) {
7745
+ return makeEnvironmentProviders([
7746
+ { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
7747
+ ]);
7748
+ }
7749
+ /** Register all 27 built-in field type keys. Convenience for apps that use most of them. */
7750
+ function provideBuiltInFieldTypes() {
7751
+ return provideFieldTypes(builtInFieldTypes());
7752
+ }
7753
+
7754
+ /**
7755
+ * HookRegistryService — stores and runs Angular-side pre/post hooks.
7756
+ * Consumers register hooks via provideNgxDynamicEntity({ hooks: { 'clients:beforeSave': fn } }).
7757
+ */
7758
+ class HookRegistryService {
7759
+ registry = inject(HOOK_REGISTRY, { optional: true }) ?? new Map();
7760
+ has(key) {
7761
+ return this.registry.has(key);
7762
+ }
7763
+ async run(key, data, context = {}) {
7764
+ const hook = this.registry.get(key);
7765
+ if (!hook)
7766
+ return data;
7767
+ return hook(data, context);
7768
+ }
7769
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
7770
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, providedIn: 'root' });
7771
+ }
7772
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: HookRegistryService, decorators: [{
7773
+ type: Injectable,
7774
+ args: [{ providedIn: 'root' }]
7775
+ }] });
7776
+
7777
+ /**
7778
+ * RulesEvaluationService — evaluates rules, and owns what a result *means*.
7779
+ *
7780
+ * Precedence lives in one method that both the render filter and the validity sync go
7781
+ * through, so what is on screen and what counts toward validity cannot disagree. Stateless —
7782
+ * every method takes the result it is interpreting, so there is nothing to go stale against a
7783
+ * rebuilt form and nothing to reset between configs.
7784
+ */
7785
+ class RulesEvaluationService {
7786
+ /**
7787
+ * Evaluate rules against form values and optional original baseline values.
7788
+ */
7789
+ evaluate(rules, values, originalValues, options) {
7790
+ return evaluateFormRules(rules, values, originalValues, options);
7791
+ }
7792
+ /**
7793
+ * Filter rules pertinent to a single tab.
7794
+ */
7795
+ filterForTab(rules, tabId, config) {
7796
+ return filterRulesForTab(rules, tabId, config);
7797
+ }
7798
+ /**
7799
+ * Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
7800
+ *
7801
+ * An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
7802
+ * `showWhen` that does not currently hold — because a rule that could only hide what the
7803
+ * config already hides would have nothing to say, and `{ visibility: true }` was a
7804
+ * documented no-op until it did. Otherwise the config's own visibility decides.
7805
+ */
7806
+ isFieldVisible(result, field, names, values) {
7807
+ const sets = this.nameSets(result);
7808
+ if (names.some(name => sets.hidden.has(name)))
7809
+ return false;
7810
+ if (names.some(name => sets.shown.has(name)))
7811
+ return true;
7812
+ return evaluateFieldVisibility(field, values);
7813
+ }
7814
+ /** The same precedence for a tab. */
7815
+ isTabVisible(result, tab) {
7816
+ if (result.hiddenTabs.includes(tab.id))
7817
+ return false;
7818
+ return tab.visibility !== false || result.shownTabs.includes(tab.id);
7819
+ }
7820
+ /**
7821
+ * Keep a hidden field's control out of form validity.
7822
+ *
7823
+ * A field hidden by a rule or a `showWhen` is filtered out of the render, but its
7824
+ * validators stay attached — so a hidden *required* field holds `form.invalid` true
7825
+ * forever, permanently disabling Save with nothing on screen to explain why.
7826
+ *
7827
+ * Disabling is the fix rather than stripping validators: Angular excludes disabled controls
7828
+ * from validity, and the field's own validators survive intact for when it comes back.
7829
+ * Values are untouched — a disabled control still carries its value, so rule evaluation and
7830
+ * the emitted record see exactly what they saw before. That also keeps this from feeding
7831
+ * back on itself: hiding a field cannot change the values the rules are evaluated over.
7832
+ *
7833
+ * The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
7834
+ * screen and what counts toward validity cannot drift apart.
7835
+ *
7836
+ * A tab that is not rendered — hidden by a rule, or `visibility: false` with no rule showing
7837
+ * it — hides everything it owns, sub-tabs and container children included. Only fields were
7838
+ * checked here, so a rule hiding a tab with a required field on it left the form invalid
7839
+ * with the field out of sight and out of the error summary: Save did nothing, silently.
7840
+ * Import already relaxed a hidden tab's fields, so the form refused rows import accepted.
7841
+ *
7842
+ * Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
7843
+ * children are skipped once the container itself is hidden, because Angular's `disable()`
7844
+ * cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
7845
+ * Toggling a child inside a hidden group would fight its parent.
7846
+ */
7847
+ syncHiddenFieldState(entries, ctx) {
7848
+ const hiddenContainers = [];
7849
+ const onHiddenTab = this.fieldsOnHiddenTabs(ctx.result, ctx.config);
7850
+ for (const entry of entries) {
7851
+ const field = entry.field;
7852
+ if (!field?.id)
7853
+ continue;
7854
+ const address = ctx.addressOf(field);
7855
+ if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
7856
+ continue;
7857
+ const hidden = onHiddenTab.has(field) || !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
7858
+ if (hidden && (field.type === 'group' || field.type === 'array'))
7859
+ hiddenContainers.push(address);
7860
+ const ctrl = ctx.controlFor(entry);
7861
+ if (!ctrl)
7862
+ continue;
7863
+ if (hidden) {
7864
+ if (ctrl.enabled)
7865
+ ctrl.disable({ emitEvent: false });
7866
+ }
7867
+ else if (ctrl.disabled && !field.disabled) {
7868
+ ctrl.enable({ emitEvent: false });
7869
+ }
7870
+ }
7871
+ }
7872
+ /**
7873
+ * Every field owned by a tab that does not render, under `isTabVisible`'s precedence.
7874
+ *
7875
+ * Stops descending at the first hidden tab: `fieldsUnderTab` already covers its sub-tabs,
7876
+ * and a sub-tab of a hidden tab is not rendered whatever its own visibility says.
7877
+ */
7878
+ fieldsOnHiddenTabs(result, config) {
7879
+ const out = new Set();
7880
+ const visit = (tabs) => {
7881
+ for (const tab of Array.isArray(tabs) ? tabs : []) {
7882
+ if (!tab || typeof tab !== 'object' || !tab.id)
7883
+ continue;
7884
+ if (this.isTabVisible(result, tab)) {
7885
+ visit(tab.children);
7886
+ }
7887
+ else {
7888
+ for (const entry of fieldsUnderTab(config, tab.id))
7889
+ out.add(entry.field);
7890
+ }
7891
+ }
7892
+ };
7893
+ visit(config?.tabs);
7894
+ return out;
7895
+ }
7896
+ /**
7897
+ * The hidden and shown names as sets, memoised against the result they came from.
7898
+ *
7899
+ * `isFieldVisible` runs once per field per change-detection pass; building two sets from
7900
+ * two arrays each time turns a rule result into allocation proportional to the form. A
7901
+ * `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
7902
+ */
7903
+ nameSets(result) {
7904
+ let sets = this.nameSetCache.get(result);
7905
+ if (!sets) {
7906
+ sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
7907
+ this.nameSetCache.set(result, sets);
7908
+ }
7909
+ return sets;
7910
+ }
7911
+ nameSetCache = new WeakMap();
7912
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
7913
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
7914
+ }
7915
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RulesEvaluationService, decorators: [{
7916
+ type: Injectable,
7917
+ args: [{ providedIn: 'root' }]
7918
+ }] });
7919
+
7920
+ class ConfigSourceService {
7921
+ cache = new Map();
7922
+ inFlight = new Map();
7923
+ handler;
7924
+ constructor(handler) {
7925
+ this.handler = handler;
7926
+ }
7927
+ /** Retrieve an entity config by key, with caching and in-flight deduplication. */
7928
+ async getConfig(entityKey) {
7929
+ if (!entityKey || !this.handler)
7930
+ return undefined;
7931
+ if (this.cache.has(entityKey))
7932
+ return this.cache.get(entityKey);
7933
+ if (this.inFlight.has(entityKey))
7934
+ return this.inFlight.get(entityKey);
7935
+ const promise = (async () => {
7936
+ try {
7937
+ const raw = this.handler(entityKey);
7938
+ let resolved;
7939
+ if (isObservable(raw)) {
7940
+ resolved = await firstValueFrom(raw);
7941
+ }
7942
+ else {
7943
+ resolved = await Promise.resolve(raw);
7944
+ }
7945
+ if (resolved) {
7946
+ this.cache.set(entityKey, resolved);
7947
+ }
7948
+ return resolved;
7949
+ }
7950
+ catch (err) {
7951
+ console.warn(`ConfigSourceService failed to load config for entity "${entityKey}"`, err);
7952
+ return undefined;
7953
+ }
7954
+ finally {
7955
+ this.inFlight.delete(entityKey);
7956
+ }
7957
+ })();
7958
+ this.inFlight.set(entityKey, promise);
7959
+ return promise;
7960
+ }
7961
+ /** Clear cached configs (e.g. after a config edit in the builder). */
7962
+ clearCache(entityKey) {
7963
+ if (entityKey) {
7964
+ this.cache.delete(entityKey);
6388
7965
  }
6389
7966
  else {
6390
7967
  this.cache.clear();
@@ -6525,18 +8102,11 @@ class FormStructureService {
6525
8102
  * unknown>` and needs no knowledge of refs at all — the extra keys simply resolve.
6526
8103
  */
6527
8104
  flattenValues(form, entries) {
6528
- const out = {};
6529
- for (const entry of entries) {
6530
- const field = entry.field;
6531
- if (!field?.id)
6532
- continue;
8105
+ // The naming rule is core's, shared with the importer; only where values come from is ours.
8106
+ return flattenFieldValues(entries, entry => {
6533
8107
  const ctrl = this.controlAt(form, entry);
6534
- if (!ctrl)
6535
- continue;
6536
- out[field.id] = ctrl.value;
6537
- out[toRefToken(refOf(field, entry.scope))] = ctrl.value;
6538
- }
6539
- return out;
8108
+ return ctrl ? { value: ctrl.value } : undefined;
8109
+ });
6540
8110
  }
6541
8111
  /**
6542
8112
  * Find a control by reference: a bracketed path, or a bare field id.
@@ -6716,6 +8286,12 @@ const AUTO_COL_SPAN = {
6716
8286
  boolean: 4,
6717
8287
  checkbox: 4,
6718
8288
  'entity-ref': 6,
8289
+ url: 6,
8290
+ phone: 6,
8291
+ slider: 6,
8292
+ rating: 4,
8293
+ color: 4,
8294
+ tags: 6,
6719
8295
  // Left at the full width deliberately — textarea, markdown, image, file, group and array
6720
8296
  // each hold something a half-row would crop.
6721
8297
  };
@@ -7691,6 +9267,7 @@ class DynamicFormComponent {
7691
9267
  namesOf: field => this.namesOf(field),
7692
9268
  addressOf: field => this.addressOf(field),
7693
9269
  controlFor: entry => this.controlForEntry(entry),
9270
+ config: this.config,
7694
9271
  });
7695
9272
  }
7696
9273
  /** Every field in the config, including `group`/`array` children. */
@@ -8542,7 +10119,10 @@ const defaultSheetParser = async (file) => {
8542
10119
  throw new Error(`Cannot read "${file.name}". The built-in parser handles CSV only — register a ` +
8543
10120
  'sheetParser with provideNgxDynamicEntity({ sheetParser }) to accept other formats.');
8544
10121
  }
8545
- return parseCsv(await file.text());
10122
+ // A `.tsv` says what it is. Anything else is read the way `parseCsv` reads text — the
10123
+ // separator its header line uses most — which is also how the server reads an upload.
10124
+ const tabs = /\.tsv$/i.test(file?.name ?? '');
10125
+ return parseCsv(await file.text(), tabs ? { delimiter: '\t' } : {});
8546
10126
  };
8547
10127
 
8548
10128
  /**
@@ -8590,12 +10170,18 @@ class LocalImportTransport {
8590
10170
  }
8591
10171
  async preview(file, context) {
8592
10172
  const sheet = await this.read(file);
8593
- const { columns } = deriveImportColumns(context.config, { lang: context.lang });
10173
+ // Sized from the headers exactly as the server sizes them, so both offer the same slots.
10174
+ const arrayBound = arrayBoundFor(sheet.headers, context.config, null, context.lang);
10175
+ const { columns } = deriveImportColumns(context.config, { lang: context.lang, maxArrayRows: arrayBound });
8594
10176
  return {
8595
10177
  headers: sheet.headers,
8596
- sample: sheet.rows.slice(0, SAMPLE_ROWS),
10178
+ // Rendered the way the server renders it, so a typed date shows — and previews — as the
10179
+ // calendar date the commit will store, not as a timestamp.
10180
+ sample: sheet.rows.slice(0, SAMPLE_ROWS).map(row => row.map(cellText)),
8597
10181
  rowCount: sheet.rows.length,
8598
10182
  suggestion: suggestMapping(sheet.headers, columns, context.config?.entity ?? ''),
10183
+ arrayBound,
10184
+ ...(sheet.delimiter ? { delimiter: sheet.delimiter } : {}),
8599
10185
  };
8600
10186
  }
8601
10187
  async commit(file, plan, context) {
@@ -8604,6 +10190,8 @@ class LocalImportTransport {
8604
10190
  lang: context.lang,
8605
10191
  rules: context.rules,
8606
10192
  lookups: context.lookups,
10193
+ // A `;` file means `1,5` is one and a half — the same call the server makes.
10194
+ decimal: decimalMarkFor(sheet.delimiter),
8607
10195
  });
8608
10196
  }
8609
10197
  async template(spec, format) {
@@ -8765,6 +10353,9 @@ class HttpImportTransport {
8765
10353
  sample: preview.sample ?? [],
8766
10354
  suggestion: preview.suggestion,
8767
10355
  rowCount: preview.rowCount ?? 0,
10356
+ // A server older than 2.3 does not say, and always sized for the default.
10357
+ arrayBound: preview.arrayBound ?? DEFAULT_ARRAY_ROWS,
10358
+ ...(preview.delimiter ? { delimiter: preview.delimiter } : {}),
8768
10359
  };
8769
10360
  }
8770
10361
  async commit(file, plan, context) {
@@ -8924,6 +10515,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
8924
10515
  type: Input
8925
10516
  }] } });
8926
10517
 
10518
+ /** The select's value for "keep the plan's fixed value", which is not a column. */
10519
+ const CONSTANT = -1;
8927
10520
  /**
8928
10521
  * Match the sheet's columns to the config's fields.
8929
10522
  *
@@ -8953,18 +10546,70 @@ class ImportMapperComponent {
8953
10546
  * attachment column was never going to arrive.
8954
10547
  */
8955
10548
  unsupported = signal(0, ...(ngDevMode ? [{ debugName: "unsupported" }] : /* istanbul ignore next */ []));
10549
+ /** A plan entry whose field this config no longer has. Kept, and emitted, until removed. */
10550
+ stale = signal([], ...(ngDevMode ? [{ debugName: "stale" }] : /* istanbul ignore next */ []));
10551
+ /** Repeating fields the user can add a row to. */
10552
+ arrays = signal([], ...(ngDevMode ? [{ debugName: "arrays" }] : /* istanbul ignore next */ []));
10553
+ constant = CONSTANT;
10554
+ /** Rows added by hand per repeating field, on top of what the sheet and plan imply. */
10555
+ extraSlots = new Map();
10556
+ /** The incoming plan, read through the 2.2 alias. Every entry in it survives `toPlan`. */
10557
+ incoming = null;
8956
10558
  ngOnChanges() {
8957
- const { columns, unsupported } = deriveImportColumns(this.config, { lang: this.language });
10559
+ this.incoming = this.plan ? upgradeLegacyRefs(this.plan, this.config) : null;
10560
+ this.extraSlots.clear();
10561
+ this.rows.set([]);
10562
+ this.rebuild();
10563
+ }
10564
+ /**
10565
+ * Rows for every field, sized so nothing the plan or the sheet names is out of reach.
10566
+ *
10567
+ * Sizing from the default three meant a sheet with `Phone 4` could not be mapped at all,
10568
+ * and a stored plan reaching further lost those entries on the next change — `toPlan` only
10569
+ * ever emitted rows, and there were none for them.
10570
+ */
10571
+ rebuild() {
10572
+ const entries = (this.incoming?.entries ?? []).filter(Boolean);
10573
+ const base = arrayBoundFor(this.headers, this.config, this.incoming, this.language);
10574
+ const extra = Math.max(0, ...this.extraSlots.values());
10575
+ // Readonly and system fields are derived so a plan that maps one keeps it, but only
10576
+ // offered as a row when the plan does — nobody maps a column onto a field the form fills.
10577
+ const { columns, unsupported } = deriveImportColumns(this.config, {
10578
+ lang: this.language,
10579
+ maxArrayRows: base + extra,
10580
+ includeReadonly: true,
10581
+ includeSystemDefault: true,
10582
+ });
8958
10583
  this.unsupported.set(unsupported.length);
8959
- const byRef = new Map((this.plan?.entries ?? []).map(entry => [entry.ref, entry]));
8960
- this.rows.set(columns.map(column => {
10584
+ const byRef = new Map(entries.map(entry => [entry.ref, entry]));
10585
+ const known = new Set(columns.map(column => column.ref));
10586
+ const previous = new Map(this.rows().map(row => [row.column.ref, row]));
10587
+ const offered = columns.filter(column => (byRef.has(column.ref) || (!column.field.readonly && !column.field.systemDefault)) &&
10588
+ (column.arrayRef === undefined ||
10589
+ byRef.has(column.ref) ||
10590
+ column.arrayIndex < base + (this.extraSlots.get(column.arrayRef) ?? 0)));
10591
+ this.rows.set(offered.map(column => {
10592
+ // A choice already made survives a rebuild for an added slot.
10593
+ const kept = previous.get(column.ref);
10594
+ if (kept)
10595
+ return { ...kept, column };
8961
10596
  const entry = byRef.get(column.ref);
8962
10597
  return {
8963
10598
  column,
8964
- source: entry?.column ?? null,
10599
+ source: entry?.column ?? (entry?.constant !== undefined ? CONSTANT : null),
8965
10600
  guessed: entry?.confidence === 'guess',
10601
+ entry,
10602
+ dirty: false,
8966
10603
  };
8967
10604
  }));
10605
+ this.stale.set(entries.filter(entry => !known.has(entry.ref)));
10606
+ const arrays = new Map();
10607
+ for (const column of offered) {
10608
+ if (column.arrayRef !== undefined && !arrays.has(column.arrayRef)) {
10609
+ arrays.set(column.arrayRef, column.arrayLabel ?? column.arrayRef);
10610
+ }
10611
+ }
10612
+ this.arrays.set([...arrays].map(([arrayRef, label]) => ({ arrayRef, label })));
8968
10613
  }
8969
10614
  mappedCount() {
8970
10615
  return this.rows().filter(row => row.source !== null).length;
@@ -8972,24 +10617,75 @@ class ImportMapperComponent {
8972
10617
  missingRequired() {
8973
10618
  return this.rows().filter(row => row.column.required && row.source === null).length;
8974
10619
  }
10620
+ hasConstant(row) {
10621
+ return !!row.entry && row.entry.constant !== undefined && row.entry.column === undefined;
10622
+ }
10623
+ constantText(entry) {
10624
+ const value = entry.constant;
10625
+ return typeof value === 'string' ? value : JSON.stringify(value);
10626
+ }
8975
10627
  choose(row, source) {
8976
10628
  this.rows.update(rows => rows.map(candidate => candidate.column.ref === row.column.ref
8977
- ? // A hand-made choice is not a guess any more, so the badge goes.
8978
- { ...candidate, source, guessed: false }
10629
+ ? // A hand-made choice is not a guess any more, so the badge goes. Going back to
10630
+ // the plan's fixed value is going back to its entry, untouched.
10631
+ { ...candidate, source, guessed: false, dirty: source !== CONSTANT }
8979
10632
  : candidate));
8980
10633
  this.planChange.emit(this.toPlan());
8981
10634
  }
8982
- /** The current selections as the plan that will be applied. */
10635
+ /** One more row for one repeating field, for this session. */
10636
+ addSlot(arrayRef) {
10637
+ this.extraSlots.set(arrayRef, (this.extraSlots.get(arrayRef) ?? 0) + 1);
10638
+ this.rebuild();
10639
+ }
10640
+ removeStale(entry) {
10641
+ this.stale.update(stale => stale.filter(candidate => candidate !== entry));
10642
+ this.planChange.emit(this.toPlan());
10643
+ }
10644
+ /**
10645
+ * The current selections as the plan that will be applied.
10646
+ *
10647
+ * Every entry of the incoming plan survives in its own position — a fixed value, a ref the
10648
+ * config no longer has — unless the user changed or removed it, and an untouched entry is
10649
+ * emitted exactly as it arrived. New selections follow, in field order.
10650
+ */
8983
10651
  toPlan() {
8984
- const entries = this.rows()
8985
- .filter(row => row.source !== null)
8986
- .map(row => ({
8987
- ref: row.column.ref,
8988
- column: row.source,
8989
- header: this.headers[row.source],
8990
- confidence: row.guessed ? 'guess' : 'exact',
8991
- }));
10652
+ const rowByRef = new Map(this.rows().map(row => [row.column.ref, row]));
10653
+ const stale = new Set(this.stale());
10654
+ const emitted = new Set();
10655
+ const entries = [];
10656
+ const entryOf = (row) => {
10657
+ if (!row.dirty && row.entry)
10658
+ return row.entry;
10659
+ if (row.source === null || row.source === CONSTANT)
10660
+ return null;
10661
+ return {
10662
+ ref: row.column.ref,
10663
+ column: row.source,
10664
+ header: this.headers[row.source],
10665
+ confidence: row.guessed ? 'guess' : 'exact',
10666
+ };
10667
+ };
10668
+ for (const original of this.incoming?.entries ?? []) {
10669
+ const row = original ? rowByRef.get(original.ref) : undefined;
10670
+ if (row) {
10671
+ emitted.add(row.column.ref);
10672
+ const entry = entryOf(row);
10673
+ if (entry)
10674
+ entries.push(entry);
10675
+ }
10676
+ else if (stale.has(original)) {
10677
+ entries.push(original);
10678
+ }
10679
+ }
10680
+ for (const row of this.rows()) {
10681
+ if (emitted.has(row.column.ref))
10682
+ continue;
10683
+ const entry = entryOf(row);
10684
+ if (entry)
10685
+ entries.push(entry);
10686
+ }
8992
10687
  return {
10688
+ ...this.incoming,
8993
10689
  entity: this.config?.entity ?? '',
8994
10690
  configVersion: this.config?.version,
8995
10691
  sourceHeaders: [...this.headers],
@@ -9038,6 +10734,11 @@ class ImportMapperComponent {
9038
10734
  (ngModelChange)="choose(row, $event)"
9039
10735
  >
9040
10736
  <option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
10737
+ @if (hasConstant(row)) {
10738
+ <option [ngValue]="constant">
10739
+ {{ ui.text('importConstant', language, { value: constantText(row.entry!) }) }}
10740
+ </option>
10741
+ }
9041
10742
  @for (header of headers; track $index) {
9042
10743
  <option [ngValue]="$index">{{ header || '—' }}</option>
9043
10744
  }
@@ -9052,8 +10753,47 @@ class ImportMapperComponent {
9052
10753
  }
9053
10754
  </tbody>
9054
10755
  </table>
10756
+
10757
+ @if (arrays().length) {
10758
+ <p class="ngx-import-mapper__slots">
10759
+ @for (array of arrays(); track array.arrayRef) {
10760
+ <button
10761
+ type="button"
10762
+ class="ngx-import-mapper__add-slot"
10763
+ [attr.data-testid]="'import-add-slot-' + array.arrayRef"
10764
+ (click)="addSlot(array.arrayRef)"
10765
+ >
10766
+ {{ ui.text('importAddSlot', language, { label: array.label }) }}
10767
+ </button>
10768
+ }
10769
+ </p>
10770
+ }
10771
+
10772
+ @if (stale().length) {
10773
+ <div class="ngx-import-mapper__stale" data-testid="import-stale" role="alert">
10774
+ <p>
10775
+ <strong>{{ ui.text('importStaleHeading', language) }}</strong>
10776
+ {{ ui.text('importStaleExplain', language) }}
10777
+ </p>
10778
+ <ul>
10779
+ @for (entry of stale(); track entry.ref) {
10780
+ <li [attr.data-testid]="'import-stale-' + entry.ref">
10781
+ <code>{{ entry.ref }}</code>
10782
+ <button
10783
+ type="button"
10784
+ class="ngx-import-mapper__remove"
10785
+ [attr.data-testid]="'import-remove-' + entry.ref"
10786
+ (click)="removeStale(entry)"
10787
+ >
10788
+ {{ ui.text('importRemoveEntry', language) }}
10789
+ </button>
10790
+ </li>
10791
+ }
10792
+ </ul>
10793
+ </div>
10794
+ }
9055
10795
  </section>
9056
- `, isInline: true, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10796
+ `, isInline: true, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__slots{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}.ngx-import-mapper__add-slot,.ngx-import-mapper__remove{padding:4px 10px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-mapper__remove{margin-left:8px}.ngx-import-mapper__stale{margin-top:12px;padding:8px 12px;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-mapper__stale p,.ngx-import-mapper__stale ul{margin:0 0 6px}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9057
10797
  }
9058
10798
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ImportMapperComponent, decorators: [{
9059
10799
  type: Component,
@@ -9098,6 +10838,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9098
10838
  (ngModelChange)="choose(row, $event)"
9099
10839
  >
9100
10840
  <option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
10841
+ @if (hasConstant(row)) {
10842
+ <option [ngValue]="constant">
10843
+ {{ ui.text('importConstant', language, { value: constantText(row.entry!) }) }}
10844
+ </option>
10845
+ }
9101
10846
  @for (header of headers; track $index) {
9102
10847
  <option [ngValue]="$index">{{ header || '—' }}</option>
9103
10848
  }
@@ -9112,8 +10857,47 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9112
10857
  }
9113
10858
  </tbody>
9114
10859
  </table>
10860
+
10861
+ @if (arrays().length) {
10862
+ <p class="ngx-import-mapper__slots">
10863
+ @for (array of arrays(); track array.arrayRef) {
10864
+ <button
10865
+ type="button"
10866
+ class="ngx-import-mapper__add-slot"
10867
+ [attr.data-testid]="'import-add-slot-' + array.arrayRef"
10868
+ (click)="addSlot(array.arrayRef)"
10869
+ >
10870
+ {{ ui.text('importAddSlot', language, { label: array.label }) }}
10871
+ </button>
10872
+ }
10873
+ </p>
10874
+ }
10875
+
10876
+ @if (stale().length) {
10877
+ <div class="ngx-import-mapper__stale" data-testid="import-stale" role="alert">
10878
+ <p>
10879
+ <strong>{{ ui.text('importStaleHeading', language) }}</strong>
10880
+ {{ ui.text('importStaleExplain', language) }}
10881
+ </p>
10882
+ <ul>
10883
+ @for (entry of stale(); track entry.ref) {
10884
+ <li [attr.data-testid]="'import-stale-' + entry.ref">
10885
+ <code>{{ entry.ref }}</code>
10886
+ <button
10887
+ type="button"
10888
+ class="ngx-import-mapper__remove"
10889
+ [attr.data-testid]="'import-remove-' + entry.ref"
10890
+ (click)="removeStale(entry)"
10891
+ >
10892
+ {{ ui.text('importRemoveEntry', language) }}
10893
+ </button>
10894
+ </li>
10895
+ }
10896
+ </ul>
10897
+ </div>
10898
+ }
9115
10899
  </section>
9116
- `, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"] }]
10900
+ `, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__slots{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}.ngx-import-mapper__add-slot,.ngx-import-mapper__remove{padding:4px 10px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-mapper__remove{margin-left:8px}.ngx-import-mapper__stale{margin-top:12px;padding:8px 12px;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-mapper__stale p,.ngx-import-mapper__stale ul{margin:0 0 6px}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"] }]
9117
10901
  }], propDecorators: { config: [{
9118
10902
  type: Input,
9119
10903
  args: [{ required: true }]
@@ -9158,13 +10942,23 @@ class ImportPreviewComponent {
9158
10942
  /** The same rules the commit will apply; without them a rule-driven rejection is invisible. */
9159
10943
  rules;
9160
10944
  language = 'en';
10945
+ /** The decimal mark number cells use — the one the commit will read them with. */
10946
+ decimal = '.';
9161
10947
  ui = inject(UiTextService);
9162
10948
  /** The columns this plan fills, in the plan's own order. */
9163
10949
  columns = signal([], ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
9164
10950
  grid = signal([], ...(ngDevMode ? [{ debugName: "grid" }] : /* istanbul ignore next */ []));
9165
10951
  ngOnChanges() {
9166
- const byRef = new Map(deriveImportColumns(this.config, { lang: this.language }).columns.map(c => [c.ref, c]));
9167
- const entries = (this.plan?.entries ?? []).filter(entry => byRef.has(entry.ref));
10952
+ // Sized by the plan, so a mapped sixth phone is shown rather than silently left out, and
10953
+ // read through the 2.2 alias, so an old ref shows the field it now means.
10954
+ const plan = upgradeLegacyRefs(this.plan, this.config);
10955
+ const byRef = new Map(deriveImportColumns(this.config, {
10956
+ lang: this.language,
10957
+ maxArrayRows: arrayBoundFor([], this.config, plan),
10958
+ includeReadonly: true,
10959
+ includeSystemDefault: true,
10960
+ }).columns.map(c => [c.ref, c]));
10961
+ const entries = (plan?.entries ?? []).filter(entry => byRef.has(entry.ref));
9168
10962
  this.columns.set(entries.map(entry => byRef.get(entry.ref)));
9169
10963
  // The same engine the commit runs, over the sample only. Row 0 of the sample is numbered
9170
10964
  // 0 here rather than 2, because these row numbers index the preview, not the file.
@@ -9172,6 +10966,7 @@ class ImportPreviewComponent {
9172
10966
  lang: this.language,
9173
10967
  lookups: this.lookups,
9174
10968
  rules: this.rules,
10969
+ decimal: this.decimal,
9175
10970
  stamp: false,
9176
10971
  firstRowNumber: 0,
9177
10972
  });
@@ -9187,6 +10982,7 @@ class ImportPreviewComponent {
9187
10982
  const outcome = coerceCell(field, raw, {
9188
10983
  lang: this.language,
9189
10984
  lookups: this.lookups,
10985
+ decimal: this.decimal,
9190
10986
  });
9191
10987
  if ('error' in outcome)
9192
10988
  return { text: outcome.error, failed: true };
@@ -9198,7 +10994,7 @@ class ImportPreviewComponent {
9198
10994
  })));
9199
10995
  }
9200
10996
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ImportPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9201
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: ImportPreviewComponent, isStandalone: true, selector: "ngx-import-preview", inputs: { config: "config", plan: "plan", rows: "rows", lookups: "lookups", rules: "rules", language: "language" }, usesOnChanges: true, ngImport: i0, template: `
10997
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: ImportPreviewComponent, isStandalone: true, selector: "ngx-import-preview", inputs: { config: "config", plan: "plan", rows: "rows", lookups: "lookups", rules: "rules", language: "language", decimal: "decimal" }, usesOnChanges: true, ngImport: i0, template: `
9202
10998
  <section class="ngx-import-preview" data-testid="import-preview">
9203
10999
  <h4 class="ngx-import-preview__heading">
9204
11000
  {{ ui.text('importPreviewHeading', language, { count: rows.length }) }}
@@ -9289,6 +11085,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9289
11085
  type: Input
9290
11086
  }], language: [{
9291
11087
  type: Input
11088
+ }], decimal: [{
11089
+ type: Input
9292
11090
  }] } });
9293
11091
 
9294
11092
  /**
@@ -9505,6 +11303,8 @@ class EntityImportComponent {
9505
11303
  headers = signal([], ...(ngDevMode ? [{ debugName: "headers" }] : /* istanbul ignore next */ []));
9506
11304
  sample = signal([], ...(ngDevMode ? [{ debugName: "sample" }] : /* istanbul ignore next */ []));
9507
11305
  rowCount = signal(0, ...(ngDevMode ? [{ debugName: "rowCount" }] : /* istanbul ignore next */ []));
11306
+ /** The separator a delimited file turned out to use, so the user can see how it was read. */
11307
+ delimiter = signal(undefined, ...(ngDevMode ? [{ debugName: "delimiter" }] : /* istanbul ignore next */ []));
9508
11308
  fileName = signal('', ...(ngDevMode ? [{ debugName: "fileName" }] : /* istanbul ignore next */ []));
9509
11309
  plan = signal(null, ...(ngDevMode ? [{ debugName: "plan" }] : /* istanbul ignore next */ []));
9510
11310
  result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
@@ -9589,6 +11389,7 @@ class EntityImportComponent {
9589
11389
  this.headers.set(preview.headers);
9590
11390
  this.sample.set(preview.sample);
9591
11391
  this.rowCount.set(preview.rowCount);
11392
+ this.delimiter.set(preview.delimiter);
9592
11393
  this.plan.set(preview.suggestion);
9593
11394
  this.step.set('map');
9594
11395
  }
@@ -9672,12 +11473,30 @@ class EntityImportComponent {
9672
11473
  failedRowCount(result) {
9673
11474
  return result.failed ?? new Set(result.errors.map(error => error.row)).size;
9674
11475
  }
11476
+ /** The decimal mark the commit will read numbers with, so the preview reads them the same. */
11477
+ decimal() {
11478
+ return decimalMarkFor(this.delimiter());
11479
+ }
11480
+ /** How the file's columns were split, in words — or nothing for a workbook. */
11481
+ delimiterText() {
11482
+ switch (this.delimiter()) {
11483
+ case ',':
11484
+ return this.ui.text('importDelimiterComma', this.language);
11485
+ case ';':
11486
+ return this.ui.text('importDelimiterSemicolon', this.language);
11487
+ case '\t':
11488
+ return this.ui.text('importDelimiterTab', this.language);
11489
+ default:
11490
+ return null;
11491
+ }
11492
+ }
9675
11493
  restart() {
9676
11494
  this.file = null;
9677
11495
  this.fileName.set('');
9678
11496
  this.headers.set([]);
9679
11497
  this.sample.set([]);
9680
11498
  this.rowCount.set(0);
11499
+ this.delimiter.set(undefined);
9681
11500
  this.plan.set(null);
9682
11501
  this.result.set(null);
9683
11502
  this.problem.set(null);
@@ -9740,6 +11559,9 @@ class EntityImportComponent {
9740
11559
  }
9741
11560
 
9742
11561
  @case ('review') {
11562
+ @if (delimiterText(); as separator) {
11563
+ <p class="ngx-entity-import__delimiter" data-testid="import-delimiter">{{ separator }}</p>
11564
+ }
9743
11565
  @if (plan(); as current) {
9744
11566
  <ngx-import-preview
9745
11567
  [config]="config"
@@ -9748,6 +11570,7 @@ class EntityImportComponent {
9748
11570
  [lookups]="resolvedLookups()"
9749
11571
  [rules]="rules"
9750
11572
  [language]="language"
11573
+ [decimal]="decimal()"
9751
11574
  />
9752
11575
  }
9753
11576
  <button
@@ -9800,7 +11623,7 @@ class EntityImportComponent {
9800
11623
  }
9801
11624
  }
9802
11625
  </div>
9803
- `, isInline: true, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"], dependencies: [{ kind: "component", type: ImportTemplateComponent, selector: "ngx-import-template", inputs: ["config", "language"], outputs: ["download"] }, { kind: "component", type: ImportMapperComponent, selector: "ngx-import-mapper", inputs: ["config", "headers", "plan", "language"], outputs: ["planChange"] }, { kind: "component", type: ImportPreviewComponent, selector: "ngx-import-preview", inputs: ["config", "plan", "rows", "lookups", "rules", "language"] }, { kind: "component", type: ImportErrorsComponent, selector: "ngx-import-errors", inputs: ["errors", "language"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11626
+ `, isInline: true, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"], dependencies: [{ kind: "component", type: ImportTemplateComponent, selector: "ngx-import-template", inputs: ["config", "language"], outputs: ["download"] }, { kind: "component", type: ImportMapperComponent, selector: "ngx-import-mapper", inputs: ["config", "headers", "plan", "language"], outputs: ["planChange"] }, { kind: "component", type: ImportPreviewComponent, selector: "ngx-import-preview", inputs: ["config", "plan", "rows", "lookups", "rules", "language", "decimal"] }, { kind: "component", type: ImportErrorsComponent, selector: "ngx-import-errors", inputs: ["errors", "language"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9804
11627
  }
9805
11628
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: EntityImportComponent, decorators: [{
9806
11629
  type: Component,
@@ -9865,6 +11688,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9865
11688
  }
9866
11689
 
9867
11690
  @case ('review') {
11691
+ @if (delimiterText(); as separator) {
11692
+ <p class="ngx-entity-import__delimiter" data-testid="import-delimiter">{{ separator }}</p>
11693
+ }
9868
11694
  @if (plan(); as current) {
9869
11695
  <ngx-import-preview
9870
11696
  [config]="config"
@@ -9873,6 +11699,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9873
11699
  [lookups]="resolvedLookups()"
9874
11700
  [rules]="rules"
9875
11701
  [language]="language"
11702
+ [decimal]="decimal()"
9876
11703
  />
9877
11704
  }
9878
11705
  <button
@@ -9972,5 +11799,5 @@ function saveBlob(blob, filename) {
9972
11799
  * Generated bundle index. Do not edit.
9973
11800
  */
9974
11801
 
9975
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
11802
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ColorFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, PhoneFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RatingFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, SliderFieldComponent, TagsFieldComponent, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, UrlFieldComponent, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, phoneValidator, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText, urlValidator };
9976
11803
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map