Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ None of `kilo`/`mega`/`milli`/`invert`/`position` change the displayed unit —
unit: kW
```

Numeric formats can be **combined** comma-separated — the value flows through each in turn and is formatted once at the end. An explicit `precision<N>` controls the decimals wherever it appears; otherwise the last format's own default applies:

```yaml
format: invert, precision3 # -18.123
format: kilo, precision1 # 1500 -> 1.5
format: invert, kilo3 # 1500 -> -1.500
```

Only number-transforming formats compose (`brightness`, `percent`, `invert`, `position`, `kilo`/`mega`/`milli`, `precision`, temperature conversions) — durations, timestamps and text transforms cannot be combined. In the visual editor, pick **Custom…** in the Format dropdown to enter a combined format.

### Hiding

The `hide_if` option can be used to hide an entity if its state or attribute value matches the specified criteria.
Expand Down
44 changes: 44 additions & 0 deletions src/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,50 @@ describe('multiple-entity-row-editor', () => {
});
});

// See https://github.com/benct/lovelace-multiple-entity-row/issues/385
describe('custom format', () => {
it('shows a pipeline format as Custom in the form data instead of clobbering it', () => {
setConfig({ entity: 'sensor.main', format: 'invert, precision3' });
expect((el as any)._mainFormData().format).toBe('__custom__');
});

it('keeps the real format when the form echoes the Custom sentinel back', () => {
setConfig({ entity: 'sensor.main', format: 'invert, precision3' });
(el as any)._mainValueChanged({
detail: { value: { entity: 'sensor.main', format: '__custom__' } },
});
expect(configs[0].format).toBe('invert, precision3');
});

it('picking Custom on a standard format preserves it and enters custom mode', () => {
setConfig({ entity: 'sensor.main', format: 'kilo' });
(el as any)._mainValueChanged({
detail: { value: { entity: 'sensor.main', format: '__custom__' } },
});
expect(configs[0].format).toBe('kilo');
expect((el as any)._isCustomFormat('main', 'kilo')).toBe(true);
});

it('picking a standard format leaves custom mode', () => {
setConfig({ entity: 'sensor.main', format: 'invert, precision3' });
(el as any)._mainValueChanged({
detail: { value: { entity: 'sensor.main', format: '__custom__' } },
});
(el as any)._mainValueChanged({
detail: { value: { entity: 'sensor.main', format: 'kilo' } },
});
const last = configs[configs.length - 1];
expect(last.format).toBe('kilo');
expect((el as any)._isCustomFormat('main', 'kilo')).toBe(false);
});

it('writes a per-entity custom format into that entity only', () => {
setConfig({ entity: 'sensor.main', entities: ['sensor.a'] });
(el as any)._setAdditionalFormat(0, 'invert, kilo3');
expect(configs[0].entities).toEqual([{ entity: 'sensor.a', format: 'invert, kilo3' }]);
});
});

describe('custom CSS block', () => {
it('parses key: value lines into the styles object', () => {
setConfig({ entity: 'sensor.main' });
Expand Down
131 changes: 124 additions & 7 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import { keyed } from 'lit/directives/keyed.js';

import { SECONDARY_INFO_VALUES } from './lib/constants';
import { fireEvent, isObject } from './util';
import { ADDITIONAL_TAB_SCHEMA, LABELS, MAIN_TAB_SCHEMA } from './editor_schemas';
import {
ACTIONS_SCHEMA,
ADDITIONAL_TAB_SCHEMA,
CUSTOM_FORMAT,
KNOWN_FORMAT_VALUES,
LABELS,
MAIN_TAB_SCHEMA,
} from './editor_schemas';
import { EntityConfig, HASS, MultipleEntityRowConfig } from './types';

type SecondaryMode = 'none' | 'text' | 'generic' | 'entity';
Expand Down Expand Up @@ -142,6 +149,7 @@ export class MultipleEntityRowEditor extends LitElement {
declare _clipboardEntity?: EntityConfig;
declare _stateIconRowsMain: StateIconRows;
declare _stateIconRowsAdditional: Map<number, StateIconRows>;
declare _customFormatScopes: Set<string>;

// Per-position stable keys for the entities tab list - lit needs these to swap tab contents
// wholesale on reorder instead of mutating sibling tab contents in place.
Expand All @@ -159,6 +167,7 @@ export class MultipleEntityRowEditor extends LitElement {
_clipboardEntity: { state: true },
_stateIconRowsMain: { state: true },
_stateIconRowsAdditional: { state: true },
_customFormatScopes: { state: true },
};
}

Expand All @@ -168,6 +177,7 @@ export class MultipleEntityRowEditor extends LitElement {
this._entitiesExpanded = true;
this._stateIconRowsMain = [];
this._stateIconRowsAdditional = new Map();
this._customFormatScopes = new Set();
}

public setConfig(config: MultipleEntityRowConfig): void {
Expand Down Expand Up @@ -374,6 +384,63 @@ export class MultipleEntityRowEditor extends LitElement {

private _computeLabel = (item: { name: string }): string => LABELS[item.name] ?? item.name;

// ── Custom format entry (see #385) ──────────────────────────────────
// The format dropdown ends with a "Custom…" sentinel. A scope is in custom mode when the
// user picked the sentinel, or when the config holds a format the dropdown doesn't know
// (comma pipelines like "invert, precision3", digit suffixes like "precision7") - without
// this, editing such a row through the form would silently clobber the YAML-only value.

private _isCustomFormat(scope: string, format?: string): boolean {
return this._customFormatScopes.has(scope) || (!!format && !KNOWN_FORMAT_VALUES.has(format));
}

private _setCustomFormatScope(scope: string, custom: boolean): void {
if (custom === this._customFormatScopes.has(scope)) return;
const next = new Set(this._customFormatScopes);
if (custom) next.add(scope);
else next.delete(scope);
this._customFormatScopes = next;
}

/** Swap a custom-mode format for the dropdown sentinel in the form's data. */
private _formatToForm<T extends { format?: string }>(scope: string, config: T): T {
return this._isCustomFormat(scope, config.format) ? { ...config, format: CUSTOM_FORMAT } : config;
}

/** Undo the sentinel on the way back out: picking "Custom…" must not overwrite the real
* format value - it only switches the scope into custom mode. */
private _formatFromForm<T extends { format?: string }>(scope: string, newConfig: T, oldFormat?: string): T {
if (newConfig.format === CUSTOM_FORMAT) {
this._setCustomFormatScope(scope, true);
const result = { ...newConfig };
if (oldFormat !== undefined) result.format = oldFormat;
else delete result.format;
return result;
}
this._setCustomFormatScope(scope, false);
return newConfig;
}

private _renderCustomFormatField(
scope: string,
format: string | undefined,
onChange: (format?: string) => void
): TemplateResult | typeof nothing {
if (!this._isCustomFormat(scope, format)) return nothing;
return html`
<ha-form
.hass=${this.hass}
.data=${{ custom_format: format ?? '' }}
.schema=${[{ name: 'custom_format', selector: { text: {} } }]}
.computeLabel=${() => 'Custom format (e.g. invert, precision3)'}
@value-changed=${(ev: CustomEvent) => {
const text = ((ev.detail.value as { custom_format?: string }).custom_format ?? '').trim();
onChange(text || undefined);
}}
></ha-form>
`;
}

// ── Entities panel: Main (tab 0) + Additional (tabs 1+) ─────────────

private _keyFor(index: number): string {
Expand Down Expand Up @@ -475,6 +542,17 @@ export class MultipleEntityRowEditor extends LitElement {
.computeLabel=${this._computeLabel}
@value-changed=${this._mainValueChanged}
></ha-form>
${this._renderCustomFormatField('main', this._config?.format, (format) =>
this._updateConfig({ format })
)}
<div class="section-label">Interactions</div>
<ha-form
.hass=${this.hass}
.data=${this._mainFormData()}
.schema=${ACTIONS_SCHEMA}
.computeLabel=${this._computeLabel}
@value-changed=${this._mainValueChanged}
></ha-form>
<div class="section-label">Secondary info</div>
${this._renderSecondaryInfoBlock()}
<div class="section-label">State-based icons</div>
Expand Down Expand Up @@ -649,11 +727,22 @@ export class MultipleEntityRowEditor extends LitElement {
<div class="child-editor">
<ha-form
.hass=${this.hass}
.data=${unitToForm(entityConfig)}
.data=${this._formatToForm(`sub-${additionalIndex}`, unitToForm(entityConfig))}
.schema=${ADDITIONAL_TAB_SCHEMA}
.computeLabel=${this._computeLabel}
@value-changed=${(ev: CustomEvent) => this._additionalValueChanged(ev, additionalIndex)}
></ha-form>
${this._renderCustomFormatField(`sub-${additionalIndex}`, entityConfig.format, (format) =>
this._setAdditionalFormat(additionalIndex, format)
)}
<div class="section-label">Interactions</div>
<ha-form
.hass=${this.hass}
.data=${this._formatToForm(`sub-${additionalIndex}`, unitToForm(entityConfig))}
.schema=${ACTIONS_SCHEMA}
.computeLabel=${this._computeLabel}
@value-changed=${(ev: CustomEvent) => this._additionalValueChanged(ev, additionalIndex)}
></ha-form>
<div class="section-label">State-based icons</div>
${this._renderStateIconRows(additionalIndex)}
<div class="section-label">Custom CSS</div>
Expand All @@ -679,12 +768,13 @@ export class MultipleEntityRowEditor extends LitElement {
/** Form data for the Main tab: seeds show_state's runtime default (ON) so the toggle reads
* correctly for configs that don't set the key, and maps `unit: false` to its text form. */
private _mainFormData(): MultipleEntityRowConfig {
return unitToForm({ show_state: true, ...this._config! });
return this._formatToForm('main', unitToForm({ show_state: true, ...this._config! }));
}

private _mainValueChanged = (ev: CustomEvent): void => {
if (!this._config) return;
const newConfig = unitFromForm({ ...(ev.detail.value as MultipleEntityRowConfig) });
let newConfig = unitFromForm({ ...(ev.detail.value as MultipleEntityRowConfig) });
newConfig = this._formatFromForm('main', newConfig, this._config.format);
// show_state: true is the seeded runtime default - strip the redundant key on the way
// back out so it doesn't pollute the YAML.
if (newConfig.show_state === true) delete newConfig.show_state;
Expand All @@ -694,10 +784,27 @@ export class MultipleEntityRowEditor extends LitElement {
private _additionalValueChanged = (ev: CustomEvent, additionalIndex: number): void => {
if (!this._config) return;
const entities = [...(this._config.entities ?? [])];
entities[additionalIndex] = unitFromForm(ev.detail.value as EntityConfig);
const raw = entities[additionalIndex];
const oldFormat = isObject(raw) ? (raw as EntityConfig).format : undefined;
entities[additionalIndex] = this._formatFromForm(
`sub-${additionalIndex}`,
unitFromForm(ev.detail.value as EntityConfig),
oldFormat
);
this._updateConfig({ entities });
};

private _setAdditionalFormat(additionalIndex: number, format?: string): void {
if (!this._config) return;
const entities = [...(this._config.entities ?? [])];
const raw = entities[additionalIndex];
const conf: EntityConfig = typeof raw === 'string' ? { entity: raw } : { ...raw };
if (format) conf.format = format;
else delete conf.format;
entities[additionalIndex] = conf;
this._updateConfig({ entities });
}

// ── Mutating handlers (Main is protected) ───────────────────────────

private _addEntity = (): void => {
Expand Down Expand Up @@ -813,11 +920,16 @@ export class MultipleEntityRowEditor extends LitElement {
? html`<div class="secondary-sub-form">
<ha-form
.hass=${this.hass}
.data=${unitToForm(si as EntityConfig)}
.data=${this._formatToForm('secondary', unitToForm(si as EntityConfig))}
.schema=${ADDITIONAL_TAB_SCHEMA}
.computeLabel=${this._computeLabel}
@value-changed=${this._secondaryEntityChanged}
></ha-form>
${this._renderCustomFormatField('secondary', (si as EntityConfig).format, (format) =>
this._updateConfig({
secondary_info: { ...(si as EntityConfig), format },
})
)}
</div>`
: nothing}
</div>
Expand Down Expand Up @@ -855,7 +967,12 @@ export class MultipleEntityRowEditor extends LitElement {
};

private _secondaryEntityChanged = (ev: CustomEvent): void => {
this._updateConfig({ secondary_info: unitFromForm(ev.detail.value as EntityConfig) });
const oldFormat = isObject(this._config?.secondary_info)
? (this._config!.secondary_info as EntityConfig).format
: undefined;
this._updateConfig({
secondary_info: this._formatFromForm('secondary', unitFromForm(ev.detail.value as EntityConfig), oldFormat),
});
};

// ── Helpers ─────────────────────────────────────────────────────────
Expand Down
28 changes: 19 additions & 9 deletions src/editor_schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// ha-form schemas for the visual editor. Adapted from the duczz/ha-multiple-entity-row fork.

// Sentinel dropdown value for "enter the format string by hand" - covers comma-separated
// pipelines (invert, precision3) and rarely-used digit suffixes not worth dropdown entries.
export const CUSTOM_FORMAT = '__custom__';

const FORMAT_OPTIONS = [
{ value: '', label: 'No format' },
{ value: 'brightness', label: 'Brightness (0-255 → %)' },
Expand Down Expand Up @@ -29,8 +33,11 @@ const FORMAT_OPTIONS = [
{ value: 'date', label: 'Timestamp: date' },
{ value: 'time', label: 'Timestamp: time' },
{ value: 'datetime', label: 'Timestamp: date + time' },
{ value: CUSTOM_FORMAT, label: 'Custom…' },
];

export const KNOWN_FORMAT_VALUES = new Set(FORMAT_OPTIONS.map((o) => o.value).filter((v) => v && v !== CUSTOM_FORMAT));

// Tab "1" - the main entity. Writes to TOP-LEVEL config keys (config.entity, config.name, ...).
export const MAIN_TAB_SCHEMA = [
{ name: 'entity', required: true, selector: { entity: {} } },
Expand Down Expand Up @@ -72,18 +79,23 @@ export const MAIN_TAB_SCHEMA = [
},
{ name: 'state_header', selector: { text: {} } },
{ name: 'image', selector: { text: {} } },
// format must stay the LAST entry: the editor renders the custom-format text box directly
// after this form, so it appears right under the format dropdown; the action selectors
// render as a separate form below it.
{ name: 'format', selector: { select: { mode: 'dropdown', options: FORMAT_OPTIONS } } },
// Row-level actions live in the Main tab, in the same format as the per-entity ones -
// a separate "Interactions" panel on screen alongside a tab's action selectors read as
// two competing blocks.
];

// Row-level actions live in the Main tab, in the same format as the per-entity ones - a
// separate "Interactions" panel on screen alongside a tab's action selectors read as two
// competing blocks. Rendered as its own form so the custom-format box can sit between.
export const ACTIONS_SCHEMA = [
{ name: 'tap_action', selector: { ui_action: { default_action: 'more-info' } } },
{ name: 'hold_action', selector: { ui_action: { default_action: 'none' } } },
{ name: 'double_tap_action', selector: { ui_action: { default_action: 'none' } } },
];

// Tabs "2".."N" - additional entities. Writes to config.entities[i-1]. Also reused for the
// secondary-info entity form, where the action selectors are inert (secondary info has no
// pointer handlers) - harmless.
// Additional-entity tabs. Writes to config.entities[i-1]. Also reused for the secondary-info
// entity form (which gets no ACTIONS_SCHEMA - secondary info has no pointer handlers).
export const ADDITIONAL_TAB_SCHEMA = [
{ name: 'entity', selector: { entity: {} } },
{
Expand All @@ -109,10 +121,8 @@ export const ADDITIONAL_TAB_SCHEMA = [
],
},
{ name: 'hide_unavailable', selector: { boolean: {} } },
// Last for the same reason as MAIN_TAB_SCHEMA - the custom-format box renders right after.
{ name: 'format', selector: { select: { mode: 'dropdown', options: FORMAT_OPTIONS } } },
{ name: 'tap_action', selector: { ui_action: { default_action: 'more-info' } } },
{ name: 'hold_action', selector: { ui_action: { default_action: 'none' } } },
{ name: 'double_tap_action', selector: { ui_action: { default_action: 'none' } } },
];

export const LABELS: Record<string, string> = {
Expand Down
Loading
Loading