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.
- package/README.md +77 -5
- package/fesm2022/ngx-dynamic-entity.mjs +2091 -264
- package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
- package/package.json +6 -3
- package/styles.css +210 -0
- package/types/ngx-dynamic-entity.d.ts +367 -17
|
@@ -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,
|
|
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 = [
|
|
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
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6235
|
-
*
|
|
6236
|
-
*
|
|
6237
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
6250
|
-
|
|
6227
|
+
instanceId = nextFieldInstanceId();
|
|
6228
|
+
domId(suffix = '') {
|
|
6229
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
6251
6230
|
}
|
|
6252
|
-
/**
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
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
|
-
|
|
6269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
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
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
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
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
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
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
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
|
-
|
|
6529
|
-
|
|
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
|
-
|
|
6535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
8960
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
8990
|
-
|
|
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
|
-
|
|
9167
|
-
|
|
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
|