eru-grid 0.0.54 → 0.0.56

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.
@@ -194,9 +194,23 @@ const SELF_COLOURED_DATATYPES = new Set(['status', 'priority', 'tag', 'progress'
194
194
  */
195
195
  const ACTION_COLUMN_MIN_WIDTH = 56;
196
196
  const SEEDED_FIELD_KEYS = [
197
- 'dynamic_number', 'display_number_as', 'symbol_field', 'date_format', 'label',
197
+ 'dynamic_number', 'display_number_as', 'symbol_field', 'date_format',
198
198
  'storage_name', 'folder_name', 'default_upload',
199
199
  ];
200
+ /**
201
+ * Keys the model seeds only until the design panel overrides them EXPLICITLY,
202
+ * tracked by a companion flag rather than by "does the column hold a value".
203
+ *
204
+ * `label` is the case that forced this: it is in SEEDED_FIELD_KEYS' spirit —
205
+ * the model's label is the default and a grid may shorten it — but a column
206
+ * never arrives without one, so the seeded test ("column already has a value,
207
+ * leave it alone") was true from the first render and the model's label never
208
+ * reached a mapped column. A query-derived column kept showing its raw result
209
+ * key as the heading.
210
+ */
211
+ const EXPLICITLY_OVERRIDDEN_FIELD_KEYS = {
212
+ label: 'label_overridden',
213
+ };
200
214
  /**
201
215
  * Datatype aliases the data model emits that mean an existing DataTypes value.
202
216
  *
@@ -1149,6 +1163,30 @@ function parseCellDate(value, pattern, keepTime = false) {
1149
1163
  * the interactive cell's job, and a composite that tried to host those would be
1150
1164
  * a layout engine. A column needing that is a `page` cell.
1151
1165
  */
1166
+ /**
1167
+ * A status field with its `open_status` / `close_status` lists flattened into
1168
+ * the `options` array the status control actually reads for its colours.
1169
+ *
1170
+ * Shared rather than done at the call site: the status cell has always built
1171
+ * this, and a composite part handed the raw field instead rendered every pill
1172
+ * in the neutral fallback — the colour lives on the option, and without the
1173
+ * flattened list there is no option to match the value against.
1174
+ */
1175
+ function toStatusOptionsField(field) {
1176
+ if (!field || normalizeDatatype(field.datatype) !== 'status')
1177
+ return null;
1178
+ const combined = [...(field.open_status || []), ...(field.close_status || [])];
1179
+ return {
1180
+ ...field,
1181
+ options: combined.map((status) => ({
1182
+ name: status?.name,
1183
+ color: status?.color || '',
1184
+ df: status?.df || false,
1185
+ })),
1186
+ open_status: field.open_status || [],
1187
+ close_status: field.close_status || [],
1188
+ };
1189
+ }
1152
1190
  function formatCellValue(value, field) {
1153
1191
  if (value === null || value === undefined || value === '')
1154
1192
  return '';
@@ -4077,33 +4115,34 @@ class EruGridStore {
4077
4115
  }
4078
4116
  return this.rows();
4079
4117
  }, ...(ngDevMode ? [{ debugName: "displayData" }] : []));
4118
+ // The frozen grand total is the row the transform appended (or prepended) to
4119
+ // the pivot data, so it is only there when the transform actually produced
4120
+ // one. It skips totalling an empty result, so `data` can be [] with the
4121
+ // grand-total config still on; indexing it then yields undefined and the
4122
+ // frozen-row template renders a row that has no `_rowKey`. Read the row
4123
+ // positionally but keep it only when it really is the grand total.
4124
+ frozenGrandTotalRow = computed(() => {
4125
+ if (!this.isPivotMode() || !this.pivotResult())
4126
+ return null;
4127
+ const config = this.configuration().config;
4128
+ if (!config?.enableGrandTotal || !config?.freezeGrandTotal)
4129
+ return null;
4130
+ const data = this.pivotResult().data;
4131
+ const row = config?.grandTotalPosition === 'before' ? data[0] : data[data.length - 1];
4132
+ return row?._isGrandTotal ? row : null;
4133
+ }, ...(ngDevMode ? [{ debugName: "frozenGrandTotalRow" }] : []));
4080
4134
  // Type-safe accessors for specific modes
4081
4135
  pivotGrandTotalData = computed(() => {
4082
- let pd = [];
4083
- if (this.isPivotMode() && this.pivotResult()) {
4084
- if (this.configuration().config?.enableGrandTotal) {
4085
- if (this.configuration().config?.freezeGrandTotal) {
4086
- if (this.configuration().config?.grandTotalPosition === 'before') {
4087
- pd = [this.pivotResult().data[0]];
4088
- }
4089
- else {
4090
- pd = [this.pivotResult().data[this.pivotResult().data.length - 1]];
4091
- }
4092
- }
4093
- }
4094
- }
4095
- return pd;
4136
+ const row = this.frozenGrandTotalRow();
4137
+ return row ? [row] : [];
4096
4138
  }, ...(ngDevMode ? [{ debugName: "pivotGrandTotalData" }] : []));
4097
4139
  // Type-safe accessors for specific modes
4098
4140
  pivotDisplayData = computed(() => {
4099
4141
  if (this.isPivotMode() && this.pivotResult()) {
4100
- if (this.configuration().config?.enableGrandTotal && this.configuration().config?.freezeGrandTotal) {
4101
- if (this.configuration().config?.grandTotalPosition === 'before') {
4102
- return this.pivotResult().data.slice(1);
4103
- }
4104
- else {
4105
- return this.pivotResult().data;
4106
- }
4142
+ // Only drop the leading row when it is the frozen grand total that the
4143
+ // header renders separately, never a real data row.
4144
+ if (this.frozenGrandTotalRow() && this.configuration().config?.grandTotalPosition === 'before') {
4145
+ return this.pivotResult().data.slice(1);
4107
4146
  }
4108
4147
  return this.pivotResult().data;
4109
4148
  }
@@ -4502,6 +4541,56 @@ class EruGridStore {
4502
4541
  const rows = this.rows().map(row => row.entity_id === rowId ? { ...row, ...updates } : row);
4503
4542
  this._rows.set(rows);
4504
4543
  }
4544
+ /**
4545
+ * Merge field values into the record with this id, wherever the grid is
4546
+ * holding it — the flat row list AND the per-group buckets a board fills.
4547
+ *
4548
+ * For a consumer that edits a record somewhere else on the page and needs the
4549
+ * grid to agree without refetching. `updateRow` is not enough on two counts:
4550
+ * it only looks at `_rows`, so a grouped/board grid keeps the stale copy, and
4551
+ * it replaces top-level row keys rather than merging into `entity_data`,
4552
+ * where an entity-backed row keeps its fields.
4553
+ *
4554
+ * Unknown ids are ignored, so a caller can announce a change blindly.
4555
+ */
4556
+ patchRecord(entityId, fields) {
4557
+ if (!entityId || !fields)
4558
+ return;
4559
+ const entries = Object.entries(fields);
4560
+ if (entries.length === 0)
4561
+ return;
4562
+ const patch = (row) => {
4563
+ if (!row || row.entity_id !== entityId)
4564
+ return row;
4565
+ const next = { ...row };
4566
+ if (next.entity_data && typeof next.entity_data === 'object') {
4567
+ next.entity_data = { ...next.entity_data, ...fields };
4568
+ }
4569
+ // An array-sourced row carries its fields at the top level instead. Only
4570
+ // keys the row already has are touched, so this never invents columns.
4571
+ for (const [key, value] of entries) {
4572
+ if (key !== 'entity_data' && Object.prototype.hasOwnProperty.call(next, key)) {
4573
+ next[key] = value;
4574
+ }
4575
+ }
4576
+ return next;
4577
+ };
4578
+ const holdsRecord = (rows) => rows.some(row => row?.entity_id === entityId);
4579
+ if (holdsRecord(this._rows())) {
4580
+ this._rows.set(this._rows().map(patch));
4581
+ }
4582
+ this._groupRows.update(current => {
4583
+ let touched = false;
4584
+ const next = new Map(current);
4585
+ current.forEach((rows, groupId) => {
4586
+ if (!holdsRecord(rows))
4587
+ return;
4588
+ next.set(groupId, rows.map(patch));
4589
+ touched = true;
4590
+ });
4591
+ return touched ? next : current;
4592
+ });
4593
+ }
4505
4594
  // Selection methods
4506
4595
  selectRow(rowId) {
4507
4596
  const selectedRowIds = new Set(this.selectedRowIds());
@@ -11100,11 +11189,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
11100
11189
  * Several of the row's own columns rendered as one cell — a name over an email,
11101
11190
  * an amount over a date.
11102
11191
  *
11103
- * View-only, and text-only, by design. Each part is formatted by its OWN
11104
- * field's rules (`formatCellValue`), so a currency part keeps its symbol and
11105
- * decimals and a date part keeps its pattern; the composite itself decides only
11106
- * arrangement and emphasis. Editing stays on the underlying columns, which are
11192
+ * View-only by design: editing stays on the underlying columns, which are
11107
11193
  * usually hidden once composed.
11194
+ *
11195
+ * A part whose datatype has a visual form of its own — a rating, a progress
11196
+ * bar, a status pill, tags, a priority, people, a checkbox — is drawn with that
11197
+ * control rather than as text, since "4" and "75" tell a reader far less than
11198
+ * four stars and a bar. Everything else is formatted by its OWN field's rules
11199
+ * (`formatCellValue`), so a currency part keeps its symbol and decimals and a
11200
+ * date part keeps its pattern — wrapping those in a control would render the
11201
+ * same string in a heavier box. The composite itself decides only arrangement
11202
+ * and emphasis.
11108
11203
  */
11109
11204
  class CompositeComponent {
11110
11205
  row = input(null, ...(ngDevMode ? [{ debugName: "row" }] : []));
@@ -11121,6 +11216,25 @@ class CompositeComponent {
11121
11216
  }, ...(ngDevMode ? [{ debugName: "labelPosition" }] : []));
11122
11217
  primaryCss = computed(() => cellTextStyleToCss(this.column()?.cell_style), ...(ngDevMode ? [{ debugName: "primaryCss" }] : []));
11123
11218
  secondaryCss = computed(() => cellTextStyleToCss(this.column()?.cell_style_secondary ?? DEFAULT_SECONDARY_STYLE), ...(ngDevMode ? [{ debugName: "secondaryCss" }] : []));
11219
+ /**
11220
+ * The third part and any beyond it.
11221
+ *
11222
+ * Falls back to the secondary style when unset, so every composite authored
11223
+ * before this existed keeps the look it had — three parts sharing one style
11224
+ * is precisely what this is for, not what it breaks.
11225
+ */
11226
+ tertiaryCss = computed(() => cellTextStyleToCss(this.column()?.cell_style_tertiary
11227
+ ?? this.column()?.cell_style_secondary
11228
+ ?? DEFAULT_SECONDARY_STYLE), ...(ngDevMode ? [{ debugName: "tertiaryCss" }] : []));
11229
+ /**
11230
+ * Primary, secondary, then tertiary for the rest — keyed on the part's
11231
+ * configured slot, never on where it ended up after empty parts were dropped.
11232
+ */
11233
+ cssForSlot(slot) {
11234
+ if (slot === 0)
11235
+ return this.primaryCss();
11236
+ return slot === 1 ? this.secondaryCss() : this.tertiaryCss();
11237
+ }
11124
11238
  /**
11125
11239
  * Falls back to the composite column itself when `composite_fields` is unset
11126
11240
  * or names nothing that exists, so a column switched to `composite` before it
@@ -11135,7 +11249,8 @@ class CompositeComponent {
11135
11249
  const row = this.row();
11136
11250
  const labels = column.composite_labels || [];
11137
11251
  const parts = names.map((name, index) => {
11138
- const field = byName.get(name);
11252
+ const field = byName.get(name) ?? null;
11253
+ const value = readRowValue(row, name);
11139
11254
  // Blank means no label for THIS part, never the field's own label as a
11140
11255
  // fallback: a composite is usually built from columns whose labels are
11141
11256
  // what the header already says, so falling back printed "Upload Date"
@@ -11144,15 +11259,98 @@ class CompositeComponent {
11144
11259
  return {
11145
11260
  name,
11146
11261
  label: String(labels[index] ?? '').trim(),
11147
- text: formatCellValue(readRowValue(row, name), field),
11262
+ text: formatCellValue(value, CompositeComponent.withRowSymbol(field, row)),
11263
+ field,
11264
+ value,
11265
+ numeric: CompositeComponent.isNumeric(field),
11266
+ render: CompositeComponent.selfDrawnDatatype(field),
11267
+ slot: index,
11148
11268
  };
11149
11269
  });
11150
- const populated = parts.filter(part => part.text !== '');
11270
+ // A drawn part counts as populated even with an empty formatted string: an
11271
+ // unrated row still shows its empty stars, and a 0% bar is a real reading.
11272
+ const populated = parts.filter(part => part.text !== '' || part.render !== '');
11151
11273
  if (populated.length > 0)
11152
11274
  return populated;
11153
- const own = formatCellValue(readRowValue(row, column.name), column);
11154
- return own === '' ? [] : [{ name: column.name, label: column.label, text: own }];
11275
+ const own = formatCellValue(readRowValue(row, column.name), CompositeComponent.withRowSymbol(column, row));
11276
+ return own === '' ? [] : [{
11277
+ name: column.name,
11278
+ label: column.label,
11279
+ text: own,
11280
+ numeric: CompositeComponent.isNumeric(column),
11281
+ field: column,
11282
+ value: readRowValue(row, column.name),
11283
+ render: '',
11284
+ slot: 0,
11285
+ }];
11155
11286
  }, ...(ngDevMode ? [{ debugName: "parts" }] : []));
11287
+ /**
11288
+ * Datatypes drawn with their own control instead of as text.
11289
+ *
11290
+ * The test is "does this type carry meaning a string cannot" — stars, a bar,
11291
+ * a coloured pill, chips, avatars, a tick. A currency or a date is already
11292
+ * fully said by `formatCellValue`, so it stays text.
11293
+ */
11294
+ static SELF_DRAWN = new Set([
11295
+ 'rating', 'progress', 'status', 'tag', 'priority', 'people', 'checkbox',
11296
+ ]);
11297
+ /**
11298
+ * The field with this ROW's currency symbol resolved onto it.
11299
+ *
11300
+ * `formatCellValue` only knows the column's static `symbol`, but a
11301
+ * multi-currency column names a sibling column in `symbol_field` and takes
11302
+ * the symbol from each record — which is how the currency CELL renders it
11303
+ * (it is handed the row for exactly this). Without it a composed currency
11304
+ * part lost its symbol and read as a bare number.
11305
+ */
11306
+ static withRowSymbol(field, row) {
11307
+ const symbolField = String(field?.symbol_field || '').trim();
11308
+ if (!field || !symbolField)
11309
+ return field;
11310
+ const raw = readRowValue(row, symbolField);
11311
+ const symbol = raw === null || raw === undefined ? '' : String(raw).trim();
11312
+ return symbol ? { ...field, symbol } : field;
11313
+ }
11314
+ /** Figures are right-aligned by their own cell, so a composed one is too. */
11315
+ static isNumeric(field) {
11316
+ const datatype = effectiveValueDatatype(field ?? undefined);
11317
+ return datatype === 'currency' || datatype === 'number';
11318
+ }
11319
+ static selfDrawnDatatype(field) {
11320
+ const datatype = normalizeDatatype(field?.datatype ?? '');
11321
+ return CompositeComponent.SELF_DRAWN.has(datatype) ? datatype : '';
11322
+ }
11323
+ /**
11324
+ * A status part needs its option colours flattened the same way the status
11325
+ * CELL does, or every pill renders in the neutral fallback.
11326
+ */
11327
+ statusConfig(field) {
11328
+ return toStatusOptionsField(field);
11329
+ }
11330
+ /** Rating takes a shape of its own rather than the raw Field. */
11331
+ ratingConfig(field) {
11332
+ const config = field;
11333
+ return {
11334
+ startValue: config?.start_value || 1,
11335
+ endValue: config?.end_value || 5,
11336
+ emojiValue: config?.emoji_value || 'star',
11337
+ };
11338
+ }
11339
+ /** Controls want their own types; a row value can arrive as anything. */
11340
+ asNumber(value) {
11341
+ const n = Number(value);
11342
+ return Number.isFinite(n) ? n : 0;
11343
+ }
11344
+ asText(value) {
11345
+ return value === null || value === undefined ? null : String(value);
11346
+ }
11347
+ asList(value) {
11348
+ if (Array.isArray(value))
11349
+ return value.map(v => String(v));
11350
+ if (value === null || value === undefined || value === '')
11351
+ return null;
11352
+ return String(value).split(',').map(v => v.trim()).filter(Boolean);
11353
+ }
11156
11354
  onDrilldown(event, part) {
11157
11355
  if (!this.isDrillable())
11158
11356
  return;
@@ -11160,11 +11358,20 @@ class CompositeComponent {
11160
11358
  this.drilldownClick.emit(part.text);
11161
11359
  }
11162
11360
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CompositeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11163
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: CompositeComponent, isStandalone: true, selector: "eru-composite", inputs: { row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: false, transformFunction: null }, column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, isDrillable: { classPropertyName: "isDrillable", publicName: "isDrillable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drilldownClick: "drilldownClick" }, ngImport: i0, template: "<div class=\"eru-composite\" [class.eru-composite--inline]=\"inline()\"\n [class.eru-composite--labelled]=\"labelPosition() !== 'none'\">\n @for (part of parts(); track part.name; let first = $first; let last = $last) {\n <span class=\"eru-composite__slot\">\n @if (labelPosition() === 'left' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"secondaryCss()\">{{ part.label }}</span>\n }\n <span class=\"eru-composite__part\" [class.eru-composite__part--secondary]=\"!first\"\n [class.eru-composite__part--drillable]=\"isDrillable() && first\" [ngStyle]=\"first ? primaryCss() : secondaryCss()\"\n [title]=\"part.text\" (click)=\"onDrilldown($event, part)\">{{ part.text }}</span>\n @if (labelPosition() === 'right' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"secondaryCss()\">{{ part.label }}</span>\n }\n </span>\n @if (inline() && !last && separator()) {\n <span class=\"eru-composite__sep\" [ngStyle]=\"secondaryCss()\">{{ separator() }}</span>\n }\n }\n</div>\n", styles: [".eru-composite{display:flex;flex-direction:column;justify-content:center;gap:3px;min-width:0;width:100%;height:100%;line-height:1.3;box-sizing:border-box;padding:0 var(--grid-cell-inset-x, 8px)}.eru-composite--inline{flex-direction:row;align-items:baseline;justify-content:flex-start;gap:4px}.eru-composite__part{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.eru-composite__part--drillable{color:var(--grid-primary);cursor:pointer;text-decoration:underline}.eru-composite__sep{flex:0 0 auto}.eru-composite__part--secondary{line-height:1.15}.eru-composite__slot{display:contents}.eru-composite--labelled .eru-composite__slot{display:inline-flex;align-items:baseline;gap:4px;min-width:0}.eru-composite__label{flex:0 0 auto;white-space:nowrap;opacity:.75}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$4.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
11361
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: CompositeComponent, isStandalone: true, selector: "eru-composite", inputs: { row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: false, transformFunction: null }, column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: false, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, isDrillable: { classPropertyName: "isDrillable", publicName: "isDrillable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drilldownClick: "drilldownClick" }, ngImport: i0, template: "<div class=\"eru-composite\" [class.eru-composite--inline]=\"inline()\"\n [class.eru-composite--labelled]=\"labelPosition() !== 'none'\">\n @for (part of parts(); track part.name; let first = $first; let last = $last) {\n <span class=\"eru-composite__slot\">\n @if (labelPosition() === 'left' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"cssForSlot(part.slot)\">{{ part.label }}</span>\n }\n @if (part.render) {\n <!-- A datatype that draws itself. Rendered read-only with pointer events\n off (see .eru-composite__control): a composite is a view, so a rating\n here must not take a click that would edit the underlying column. The\n secondary slot scales the control down so the primary still leads. -->\n <span class=\"eru-composite__control\" [ngClass]=\"'eru-composite__control--' + part.render\"\n [class.eru-composite__control--secondary]=\"part.slot > 0\">\n @switch (part.render) {\n @case ('rating') {\n <eru-rating [value]=\"asNumber(part.value)\" [config]=\"ratingConfig(part.field)\" [isEditable]=\"false\"></eru-rating>\n }\n @case ('progress') {\n <eru-progress [value]=\"asNumber(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-progress>\n }\n @case ('status') {\n <eru-status [value]=\"asText(part.value)\" [config]=\"statusConfig(part.field)\" [isEditable]=\"false\"></eru-status>\n }\n @case ('tag') {\n <eru-tag [value]=\"asList(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-tag>\n }\n @case ('priority') {\n <eru-priority [value]=\"asText(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-priority>\n }\n @case ('people') {\n <eru-people [value]=\"asList(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-people>\n }\n @case ('checkbox') {\n <eru-checkbox [value]=\"part.value\" [config]=\"part.field\" [isEditable]=\"false\"></eru-checkbox>\n }\n }\n </span>\n } @else {\n <span class=\"eru-composite__part\" [class.eru-composite__part--secondary]=\"part.slot > 0\"\n [class.eru-composite__part--numeric]=\"part.numeric\"\n [class.eru-composite__part--drillable]=\"isDrillable() && part.slot === 0\" [ngStyle]=\"cssForSlot(part.slot)\"\n [title]=\"part.text\" (click)=\"onDrilldown($event, part)\">{{ part.text }}</span>\n }\n @if (labelPosition() === 'right' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"cssForSlot(part.slot)\">{{ part.label }}</span>\n }\n </span>\n @if (inline() && !last && separator()) {\n <span class=\"eru-composite__sep\" [ngStyle]=\"secondaryCss()\">{{ separator() }}</span>\n }\n }\n</div>\n", styles: [".eru-composite{display:flex;flex-direction:column;justify-content:center;gap:3px;min-width:0;width:100%;height:100%;line-height:1.3;box-sizing:border-box;padding:0 var(--grid-cell-inset-x, 8px)}.eru-composite--inline{flex-direction:row;align-items:baseline;justify-content:flex-start;gap:4px}.eru-composite__part{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.eru-composite__part--drillable{color:var(--grid-primary);cursor:pointer;text-decoration:underline}.eru-composite__sep{flex:0 0 auto}.eru-composite__part--secondary{line-height:1.15}.eru-composite__slot{display:contents}.eru-composite--labelled .eru-composite__slot{display:inline-flex;align-items:baseline;gap:4px;min-width:0}.eru-composite__label{flex:0 0 auto;white-space:nowrap;opacity:.75}.eru-composite__control{display:inline-flex;align-items:center;justify-content:flex-start;min-width:0;pointer-events:none}.eru-composite__control--progress{display:flex;width:100%}.eru-composite__control--progress>eru-progress{display:block;flex:1 1 auto;width:100%;min-width:0}.eru-composite--inline .eru-composite__control--progress{width:auto;min-width:72px}.eru-composite__control>*{margin-left:0;margin-right:0}.eru-composite__control--status .status-display,.eru-composite__control--status .status-dot-display{margin:0}.eru-composite__control--tag .tag-display,.eru-composite__control--priority .priority-display{padding:0}.eru-composite__control--progress .progress-view-container{padding:0;max-height:none}.eru-composite__control--secondary{transform:scale(.78);transform-origin:left center;margin-right:-22%}.eru-composite:not(.eru-composite--inline) .eru-composite__part--numeric{text-align:right}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$4.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$4.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: RatingComponent, selector: "eru-rating", inputs: ["value", "config", "isEditable", "isActive"], outputs: ["valueChange", "editModeChange"] }, { kind: "component", type: ProgressComponent, selector: "eru-progress", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "columnWidth", "fieldSize", "eruGridStore"], outputs: ["valueChange", "blur", "focus", "drilldownClick", "editModeChange"] }, { kind: "component", type: StatusComponent, selector: "eru-status", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "columnWidth", "fieldSize", "eruGridStore"], outputs: ["valueChange", "blur", "focus", "drilldownClick", "editModeChange"] }, { kind: "component", type: TagComponent, selector: "eru-tag", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "columnWidth", "fieldSize", "eruGridStore"], outputs: ["valueChange", "blur", "focus", "drilldownClick", "editModeChange", "newTagAdded"] }, { kind: "component", type: PriorityComponent, selector: "eru-priority", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "columnWidth", "fieldSize", "eruGridStore"], outputs: ["valueChange", "blur", "focus", "drilldownClick", "editModeChange"] }, { kind: "component", type: PeopleComponent, selector: "eru-people", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "columnWidth", "eruGridStore", "personCardTemplate"], outputs: ["valueChange", "blur", "focus", "drilldownClick", "editModeChange"] }, { kind: "component", type: CheckboxComponent, selector: "eru-checkbox", inputs: ["value", "config", "isEditable", "isActive", "isDrillable", "label"], outputs: ["valueChange", "change", "blur", "focus", "drilldownClick", "editModeChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
11164
11362
  }
11165
11363
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: CompositeComponent, decorators: [{
11166
11364
  type: Component,
11167
- args: [{ selector: 'eru-composite', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: "<div class=\"eru-composite\" [class.eru-composite--inline]=\"inline()\"\n [class.eru-composite--labelled]=\"labelPosition() !== 'none'\">\n @for (part of parts(); track part.name; let first = $first; let last = $last) {\n <span class=\"eru-composite__slot\">\n @if (labelPosition() === 'left' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"secondaryCss()\">{{ part.label }}</span>\n }\n <span class=\"eru-composite__part\" [class.eru-composite__part--secondary]=\"!first\"\n [class.eru-composite__part--drillable]=\"isDrillable() && first\" [ngStyle]=\"first ? primaryCss() : secondaryCss()\"\n [title]=\"part.text\" (click)=\"onDrilldown($event, part)\">{{ part.text }}</span>\n @if (labelPosition() === 'right' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"secondaryCss()\">{{ part.label }}</span>\n }\n </span>\n @if (inline() && !last && separator()) {\n <span class=\"eru-composite__sep\" [ngStyle]=\"secondaryCss()\">{{ separator() }}</span>\n }\n }\n</div>\n", styles: [".eru-composite{display:flex;flex-direction:column;justify-content:center;gap:3px;min-width:0;width:100%;height:100%;line-height:1.3;box-sizing:border-box;padding:0 var(--grid-cell-inset-x, 8px)}.eru-composite--inline{flex-direction:row;align-items:baseline;justify-content:flex-start;gap:4px}.eru-composite__part{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.eru-composite__part--drillable{color:var(--grid-primary);cursor:pointer;text-decoration:underline}.eru-composite__sep{flex:0 0 auto}.eru-composite__part--secondary{line-height:1.15}.eru-composite__slot{display:contents}.eru-composite--labelled .eru-composite__slot{display:inline-flex;align-items:baseline;gap:4px;min-width:0}.eru-composite__label{flex:0 0 auto;white-space:nowrap;opacity:.75}\n"] }]
11365
+ args: [{ selector: 'eru-composite', standalone: true, imports: [
11366
+ CommonModule,
11367
+ RatingComponent,
11368
+ ProgressComponent,
11369
+ StatusComponent,
11370
+ TagComponent,
11371
+ PriorityComponent,
11372
+ PeopleComponent,
11373
+ CheckboxComponent,
11374
+ ], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: "<div class=\"eru-composite\" [class.eru-composite--inline]=\"inline()\"\n [class.eru-composite--labelled]=\"labelPosition() !== 'none'\">\n @for (part of parts(); track part.name; let first = $first; let last = $last) {\n <span class=\"eru-composite__slot\">\n @if (labelPosition() === 'left' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"cssForSlot(part.slot)\">{{ part.label }}</span>\n }\n @if (part.render) {\n <!-- A datatype that draws itself. Rendered read-only with pointer events\n off (see .eru-composite__control): a composite is a view, so a rating\n here must not take a click that would edit the underlying column. The\n secondary slot scales the control down so the primary still leads. -->\n <span class=\"eru-composite__control\" [ngClass]=\"'eru-composite__control--' + part.render\"\n [class.eru-composite__control--secondary]=\"part.slot > 0\">\n @switch (part.render) {\n @case ('rating') {\n <eru-rating [value]=\"asNumber(part.value)\" [config]=\"ratingConfig(part.field)\" [isEditable]=\"false\"></eru-rating>\n }\n @case ('progress') {\n <eru-progress [value]=\"asNumber(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-progress>\n }\n @case ('status') {\n <eru-status [value]=\"asText(part.value)\" [config]=\"statusConfig(part.field)\" [isEditable]=\"false\"></eru-status>\n }\n @case ('tag') {\n <eru-tag [value]=\"asList(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-tag>\n }\n @case ('priority') {\n <eru-priority [value]=\"asText(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-priority>\n }\n @case ('people') {\n <eru-people [value]=\"asList(part.value)\" [config]=\"part.field\" [isEditable]=\"false\"></eru-people>\n }\n @case ('checkbox') {\n <eru-checkbox [value]=\"part.value\" [config]=\"part.field\" [isEditable]=\"false\"></eru-checkbox>\n }\n }\n </span>\n } @else {\n <span class=\"eru-composite__part\" [class.eru-composite__part--secondary]=\"part.slot > 0\"\n [class.eru-composite__part--numeric]=\"part.numeric\"\n [class.eru-composite__part--drillable]=\"isDrillable() && part.slot === 0\" [ngStyle]=\"cssForSlot(part.slot)\"\n [title]=\"part.text\" (click)=\"onDrilldown($event, part)\">{{ part.text }}</span>\n }\n @if (labelPosition() === 'right' && part.label) {\n <span class=\"eru-composite__label\" [ngStyle]=\"cssForSlot(part.slot)\">{{ part.label }}</span>\n }\n </span>\n @if (inline() && !last && separator()) {\n <span class=\"eru-composite__sep\" [ngStyle]=\"secondaryCss()\">{{ separator() }}</span>\n }\n }\n</div>\n", styles: [".eru-composite{display:flex;flex-direction:column;justify-content:center;gap:3px;min-width:0;width:100%;height:100%;line-height:1.3;box-sizing:border-box;padding:0 var(--grid-cell-inset-x, 8px)}.eru-composite--inline{flex-direction:row;align-items:baseline;justify-content:flex-start;gap:4px}.eru-composite__part{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.eru-composite__part--drillable{color:var(--grid-primary);cursor:pointer;text-decoration:underline}.eru-composite__sep{flex:0 0 auto}.eru-composite__part--secondary{line-height:1.15}.eru-composite__slot{display:contents}.eru-composite--labelled .eru-composite__slot{display:inline-flex;align-items:baseline;gap:4px;min-width:0}.eru-composite__label{flex:0 0 auto;white-space:nowrap;opacity:.75}.eru-composite__control{display:inline-flex;align-items:center;justify-content:flex-start;min-width:0;pointer-events:none}.eru-composite__control--progress{display:flex;width:100%}.eru-composite__control--progress>eru-progress{display:block;flex:1 1 auto;width:100%;min-width:0}.eru-composite--inline .eru-composite__control--progress{width:auto;min-width:72px}.eru-composite__control>*{margin-left:0;margin-right:0}.eru-composite__control--status .status-display,.eru-composite__control--status .status-dot-display{margin:0}.eru-composite__control--tag .tag-display,.eru-composite__control--priority .priority-display{padding:0}.eru-composite__control--progress .progress-view-container{padding:0;max-height:none}.eru-composite__control--secondary{transform:scale(.78);transform-origin:left center;margin-right:-22%}.eru-composite:not(.eru-composite--inline) .eru-composite__part--numeric{text-align:right}\n"] }]
11168
11375
  }], propDecorators: { row: [{ type: i0.Input, args: [{ isSignal: true, alias: "row", required: false }] }], column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], isDrillable: [{ type: i0.Input, args: [{ isSignal: true, alias: "isDrillable", required: false }] }], drilldownClick: [{ type: i0.Output, args: ["drilldownClick"] }] } });
11169
11376
  const DEFAULT_SECONDARY_STYLE = {
11170
11377
  font_size: 11,
@@ -11616,32 +11823,9 @@ class DataCellComponent {
11616
11823
  onSelectDrilldown(value) {
11617
11824
  this.handleDrilldown(value);
11618
11825
  }
11619
- // Status component handlers
11620
- getStatusConfig = computed(() => {
11621
- const config = this.columnCellConfiguration();
11622
- if (!config || config.datatype !== 'status') {
11623
- return null;
11624
- }
11625
- // Combine open and close status
11626
- const combinedStatus = [
11627
- ...(config.open_status || []),
11628
- ...(config.close_status || [])
11629
- ];
11630
- // Transform to Field format with options array
11631
- // Each option should have 'name' and 'color' properties
11632
- const options = combinedStatus.map((status) => ({
11633
- name: status.name,
11634
- color: status.color || '',
11635
- df: status.df || false
11636
- }));
11637
- // Return Field object with combined options and preserved open/close arrays
11638
- return {
11639
- ...config,
11640
- options: options,
11641
- open_status: config.open_status || [],
11642
- close_status: config.close_status || []
11643
- };
11644
- }, ...(ngDevMode ? [{ debugName: "getStatusConfig" }] : []));
11826
+ // Status component handlers. The flattening lives in the model so a composed
11827
+ // status part builds the same config from the same definition.
11828
+ getStatusConfig = computed(() => toStatusOptionsField(this.columnCellConfiguration()), ...(ngDevMode ? [{ debugName: "getStatusConfig" }] : []));
11645
11829
  onStatusBlur(value) {
11646
11830
  this.currentValue.set(value);
11647
11831
  this.eruGridStore().setActiveCell(null);
@@ -13337,6 +13521,14 @@ const COMPOSITE_FIELDS = [
13337
13521
  showWhen: f => f.composite_label_position === 'left' || f.composite_label_position === 'right',
13338
13522
  },
13339
13523
  { key: 'cell_style_secondary', label: 'Secondary value style', control: 'text_style' },
13524
+ {
13525
+ // Only once a third part exists: on a two-part composite the control would
13526
+ // style nothing, and the panel already runs long.
13527
+ key: 'cell_style_tertiary',
13528
+ label: 'Third value style',
13529
+ control: 'text_style',
13530
+ showWhen: f => (f.composite_fields || []).filter(Boolean).length > 2,
13531
+ },
13340
13532
  ];
13341
13533
  const PAGE_CELL_FIELDS = [
13342
13534
  { key: 'cell_page_id', label: 'Cell page', control: 'text' },
@@ -13756,9 +13948,13 @@ class ColumnDesignPanelComponent {
13756
13948
  * drift apart: a key that is inherited is always locked, and vice versa.
13757
13949
  *
13758
13950
  * SEEDED_FIELD_KEYS are the exception the resolver makes too: the model seeds
13759
- * them, this grid may then override them, so they stay editable here.
13951
+ * them, this grid may then override them, so they stay editable here. So are
13952
+ * EXPLICITLY_OVERRIDDEN_FIELD_KEYS, seeded the same way but tracked by a flag
13953
+ * — `label` among them, which must stay typeable for the flag to ever be set.
13760
13954
  */
13761
- static INHERITED_KEYS = new Set(INHERITED_FIELD_KEYS.filter(k => !SEEDED_FIELD_KEYS.includes(k) && k !== 'datatype'));
13955
+ static INHERITED_KEYS = new Set(INHERITED_FIELD_KEYS.filter(k => !SEEDED_FIELD_KEYS.includes(k) &&
13956
+ !EXPLICITLY_OVERRIDDEN_FIELD_KEYS[k] &&
13957
+ k !== 'datatype'));
13762
13958
  isInherited(key) {
13763
13959
  return this.isEntityMapped() && ColumnDesignPanelComponent.INHERITED_KEYS.has(key);
13764
13960
  }
@@ -14045,6 +14241,20 @@ class ColumnDesignPanelComponent {
14045
14241
  this.gridStore.updateColumnMeta(name, { [key]: value });
14046
14242
  }
14047
14243
  onText(key, value) {
14244
+ // A typed heading is the author's, and stops the mapped field's label from
14245
+ // seeding it on the next resolve; clearing the box hands the column back to
14246
+ // the data model rather than pinning it to an empty header.
14247
+ const flag = EXPLICITLY_OVERRIDDEN_FIELD_KEYS[key];
14248
+ if (flag) {
14249
+ const name = this.gridStore.selectedDesignColumn();
14250
+ if (!name)
14251
+ return;
14252
+ const typed = String(value ?? '').trim();
14253
+ this.gridStore.updateColumnMeta(name, typed
14254
+ ? { [key]: value, [flag]: true }
14255
+ : { [key]: null, [flag]: null });
14256
+ return;
14257
+ }
14048
14258
  this.patch(key, value);
14049
14259
  }
14050
14260
  onNumber(key, value) {
@@ -15789,7 +15999,7 @@ class EruGridComponent {
15789
15999
  }
15790
16000
  }
15791
16001
  trackByPivotRowFn(index, pivotRow) {
15792
- return pivotRow._rowKey || `pivot-row-${index}`;
16002
+ return pivotRow?._rowKey || `pivot-row-${index}`;
15793
16003
  }
15794
16004
  trackByRowFn(index, row) {
15795
16005
  // Use both entity_id and index to ensure uniqueness even with duplicate entity_ids
@@ -16121,6 +16331,11 @@ class EruGridComponent {
16121
16331
  * including an explicit `false` on a checkbox — wins from then on. Without the
16122
16332
  * column in hand there is no way to tell "not set" from "set to the opposite",
16123
16333
  * so an override was overwritten on every resolve.
16334
+ *
16335
+ * EXPLICITLY_OVERRIDDEN_FIELD_KEYS work the same way but ask a companion flag
16336
+ * instead, for keys a column always holds a value for — `label`, whose
16337
+ * "already set" was true from the first render, so the model's label never
16338
+ * won and a mapped column kept its raw result key as the heading.
16124
16339
  */
16125
16340
  buildMappedColumnPatch(meta, col) {
16126
16341
  const patch = {};
@@ -16132,6 +16347,9 @@ class EruGridComponent {
16132
16347
  continue;
16133
16348
  if (seeded.has(k) && !isUnset(col?.[k]))
16134
16349
  continue;
16350
+ const overrideFlag = EXPLICITLY_OVERRIDDEN_FIELD_KEYS[k];
16351
+ if (overrideFlag && col?.[overrideFlag])
16352
+ continue;
16135
16353
  patch[k] = v;
16136
16354
  }
16137
16355
  // setColumns() stores the canonical datatype ('r_status' -> 'status'), so a
@@ -17033,5 +17251,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
17033
17251
  * Generated bundle index. Do not edit.
17034
17252
  */
17035
17253
 
17036
- export { ACTION_COLUMN_MIN_WIDTH, AttachmentComponent, CELL_RULE_OPERATORS, CheckboxComponent, ChipListComponent, ColumnConstraintsService, CompositeComponent, CurrencyComponent, CustomVirtualScrollStrategy, DATA_TYPES, DATETIME_FORMATS, DATE_FORMATS, DateComponent, DatetimeComponent, DurationComponent, EmailComponent, EruGridComponent, EruGridService, EruGridStore, GRID_COLOR_TOKENS, INHERITED_FIELD_KEYS, LocationComponent, MATERIAL_MODULES, MATERIAL_PROVIDERS, MONTH_SHORT_NAMES, NumberComponent, NumericInputDirective, PRESENTATION_DATATYPES, PRESET_CONFIG_DEFAULTS, PRESET_MANAGED_FIELDS, PeopleComponent, PhoneComponent, PriorityComponent, ProgressComponent, RatingComponent, SEEDED_FIELD_KEYS, SELF_COLOURED_DATATYPES, SelectComponent, StatusComponent, TagComponent, TextareaComponent, TextboxComponent, ThemeService, ThemeToggleComponent, WebsiteComponent, abbreviateNumber, abbreviationScaleFor, cellRuleBarPercent, cellRuleMatches, cellRuleToCss, cellStatAlias, cellTextStyleToCss, collectColumnStatRequests, columnCellRules, composeColorValue, effectiveValueDatatype, evaluateRowCondition, formatCellValue, formatDateTimeWithPattern, formatDateWithPattern, formatNumberValue, hasOwnCellRules, matchCellRule, matchColorRange, normalizeCellRender, normalizeDatatype, normalizeDateFormat, normalizeDateTimeFormat, parseCellDate, parseCellDateTime, parseColorValue, parseDateWithPattern, readRowValue, resolveColumnRules, resolveRowValue, resolveStatValue, rowSelectionKey, statusPillColors, tagPillColors, toDateOnlyString, toDateTimeString };
17254
+ export { ACTION_COLUMN_MIN_WIDTH, AttachmentComponent, CELL_RULE_OPERATORS, CheckboxComponent, ChipListComponent, ColumnConstraintsService, CompositeComponent, CurrencyComponent, CustomVirtualScrollStrategy, DATA_TYPES, DATETIME_FORMATS, DATE_FORMATS, DateComponent, DatetimeComponent, DurationComponent, EXPLICITLY_OVERRIDDEN_FIELD_KEYS, EmailComponent, EruGridComponent, EruGridService, EruGridStore, GRID_COLOR_TOKENS, INHERITED_FIELD_KEYS, LocationComponent, MATERIAL_MODULES, MATERIAL_PROVIDERS, MONTH_SHORT_NAMES, NumberComponent, NumericInputDirective, PRESENTATION_DATATYPES, PRESET_CONFIG_DEFAULTS, PRESET_MANAGED_FIELDS, PeopleComponent, PhoneComponent, PriorityComponent, ProgressComponent, RatingComponent, SEEDED_FIELD_KEYS, SELF_COLOURED_DATATYPES, SelectComponent, StatusComponent, TagComponent, TextareaComponent, TextboxComponent, ThemeService, ThemeToggleComponent, WebsiteComponent, abbreviateNumber, abbreviationScaleFor, cellRuleBarPercent, cellRuleMatches, cellRuleToCss, cellStatAlias, cellTextStyleToCss, collectColumnStatRequests, columnCellRules, composeColorValue, effectiveValueDatatype, evaluateRowCondition, formatCellValue, formatDateTimeWithPattern, formatDateWithPattern, formatNumberValue, hasOwnCellRules, matchCellRule, matchColorRange, normalizeCellRender, normalizeDatatype, normalizeDateFormat, normalizeDateTimeFormat, parseCellDate, parseCellDateTime, parseColorValue, parseDateWithPattern, readRowValue, resolveColumnRules, resolveRowValue, resolveStatValue, rowSelectionKey, statusPillColors, tagPillColors, toDateOnlyString, toDateTimeString, toStatusOptionsField };
17037
17255
  //# sourceMappingURL=eru-grid.mjs.map