Skip to content

Commit

Permalink
Extend templating (#102)
Browse files Browse the repository at this point in the history
* Added styles template
* Added templating for title
* Added templating for name
  • Loading branch information
marcokreeft87 authored Oct 19, 2022
1 parent 9fd76f3 commit 1ad9729
Show file tree
Hide file tree
Showing 16 changed files with 206 additions and 72 deletions.
6 changes: 6 additions & 0 deletions info.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
{% if installed %}

### Features
{% if version_installed.replace("v", "").replace(".","") | int < 10700 %}
- Added `Support for templating for styles`
- Added `Support for templating for title`
- Added `Support for templating for name`
{% endif %}

{% if version_installed.replace("v", "").replace(".","") | int < 10640 %}
- Added `content_alignment (left, center or right) for rows`
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "room-card",
"version": "1.06.41",
"version": "1.07.00",
"description": "Show entities in Home Assistant's Lovelace UI",
"keywords": [
"home-assistant",
Expand Down
18 changes: 9 additions & 9 deletions room-card.js

Large diffs are not rendered by default.

Binary file modified room-card.js.gz
Binary file not shown.
49 changes: 32 additions & 17 deletions src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { formatNumber } from './lib/format_number';
import { computeStateDisplay, computeStateDomain } from './lib/compute_state_display';
import { checkConditionalValue, evalTemplate, getValue, isObject, isUnavailable, renderClasses } from './util';
import { ActionConfig, handleClick, HomeAssistant } from 'custom-card-helpers';
import { HomeAssistantEntity, EntityCondition, RoomCardEntity, RoomCardIcon, RoomCardConfig, EntityStyles, RoomCardRow } from './types/room-card-types';
import { HomeAssistantEntity, EntityCondition, RoomCardEntity, RoomCardIcon, RoomCardConfig, EntityStyles, RoomCardRow, RoomCardAttributeTemplate } from './types/room-card-types';
import { html, HTMLTemplateResult, LitElement } from 'lit';
import { LAST_CHANGED, LAST_UPDATED, TIMESTAMP_FORMATS } from './lib/constants';
import { templateStyling } from './template';
import { getTemplateOrAttribute, templateStyling } from './template';
import { hideIfEntity, hideIfRow } from './hide';

export const checkConfig = (config: RoomCardConfig) => {
Expand All @@ -17,9 +17,11 @@ export const checkConfig = (config: RoomCardConfig) => {

export const computeEntity = (entityId: string) => entityId.substr(entityId.indexOf('.') + 1);

export const entityName = (entity: RoomCardEntity) => {
export const entityName = (entity: RoomCardEntity, hass: HomeAssistant) => {
const name = getTemplateOrAttribute(entity.name, hass, entity.stateObj)

return (
entity.name ||
name ||
(entity.entity ? entity.stateObj.attributes.friendly_name || computeEntity(entity.stateObj.entity_id) : null) ||
null
);
Expand Down Expand Up @@ -116,14 +118,23 @@ export const entityStateDisplay = (hass: HomeAssistant, entity: RoomCardEntity)
return computeStateDisplay(hass.localize, modifiedStateObj, hass.locale);
};

export const entityStyles = (styles: EntityStyles) =>
isObject(styles)
? Object.keys(styles)
.map((key) => `${key}: ${styles[key]};`)
.join('')
: '';
export const entityStyles = (styles: EntityStyles | RoomCardAttributeTemplate, stateObj: HomeAssistantEntity, hass: HomeAssistant) => {
if(!styles) {
return '';
}

if ('template' in styles) {
const templateDefinition = styles as RoomCardAttributeTemplate;
return evalTemplate(hass, stateObj, templateDefinition.template);
}

const entityStyles = styles as EntityStyles;
return Object.keys(entityStyles)
.map((key) => `${key}: ${entityStyles[key]};`)
.join('');
}

export const renderRows = (config: RoomCardConfig, rows: RoomCardRow[], hass: HomeAssistant, element: LitElement) : HTMLTemplateResult => {
export const renderRows = (rows: RoomCardRow[], hass: HomeAssistant, element: LitElement) : HTMLTemplateResult => {
const filteredRows = rows.filter(row => { return !hideIfRow(row, hass); });

return html`${filteredRows.map((row) => {
Expand Down Expand Up @@ -183,9 +194,9 @@ export const renderEntity = (entity: RoomCardEntity, hass: HomeAssistant, elemen
}
};

return html`<div class="entity" style="${entityStyles(entity.styles)}"
return html`<div class="entity" style="${entityStyles(entity.styles, hass.states[entity.entity], hass)}"
@mousedown="${start}" @mouseup="${end}" @touchstart="${start}" @touchend="${end}" @touchcancel="${end}">
${entity.show_name === undefined || entity.show_name ? html`<span>${entityName(entity)}</span>` : ''}
${entity.show_name === undefined || entity.show_name ? html`<span>${entityName(entity, hass)}</span>` : ''}
<div>${renderIcon(entity.stateObj, entity, hass)}</div>
${entity.show_state ? html`<span>${entityStateDisplay(hass, entity)}</span>` : ''}
</div>`;
Expand All @@ -204,7 +215,7 @@ export const renderIcon = (stateObj: HomeAssistantEntity, config: RoomCardEntity
.stateObj="${stateObj}"
.overrideIcon="${isObject(customIcon) ? (customIcon as EntityCondition).icon : customIcon as string}"
.stateColor="${config.state_color}"
style="${customStyling ?? entityStyles(isObject(customIcon) ? (customIcon as EntityCondition).styles : null)}"
style="${customStyling ?? entityStyles(isObject(customIcon) ? (customIcon as EntityCondition).styles : null, hass.states[config.entity], hass)}"
></state-badge>`;
}

Expand Down Expand Up @@ -245,9 +256,12 @@ export const renderMainEntity = (entity: RoomCardEntity | undefined, config: Roo
if (entity === undefined) {
return null;
}

const stateObj = hass.states[entity.entity];

return html`<div
class="main-state entity"
style="${entityStyles(entity.styles)}">
style="${entityStyles(entity.styles, stateObj, hass)}">
${config.entities?.length === 0 || config.icon
? renderIcon(entity.stateObj, config, hass, "main-icon")
: entity.show_state !== undefined && entity.show_state === false ? '' : renderValue(entity, hass)}
Expand All @@ -261,8 +275,9 @@ export const renderTitle = (entity: RoomCardEntity, config: RoomCardConfig, hass
const onClick = clickHandler(entity?.stateObj?.entity_id, config.tap_action, hass, element);
const onDblClick = dblClickHandler(entity?.stateObj?.entity_id, config.double_tap_action, hass, element);
const hasAction = config.tap_action !== undefined || config.double_tap_action !== undefined;
const title = getTemplateOrAttribute(config.title, hass, entity?.stateObj);

return html`<div class="title${(hasAction ? ' clickable' : null)}" @click="${onClick}" @dblclick="${onDblClick}">${renderMainEntity(entity, config, hass)} ${config.title}</div>`;
return html`<div class="title${(hasAction ? ' clickable' : null)}" @click="${onClick}" @dblclick="${onDblClick}">${renderMainEntity(entity, config, hass)} ${title}</div>`;
}

export const renderInfoEntity = (entity: RoomCardEntity, hass: HomeAssistant, element: LitElement) : HTMLTemplateResult => {
Expand All @@ -271,7 +286,7 @@ export const renderInfoEntity = (entity: RoomCardEntity, hass: HomeAssistant, el
}

const onClick = clickHandler(entity.stateObj.entity_id, entity.tap_action, hass, element);
return html`<div class="state entity ${entity.show_icon === true ? 'icon-entity' : ''}" style="${entityStyles(entity.styles)}" @click="${onClick}">${renderValue(entity, hass)}</div>`;
return html`<div class="state entity ${entity.show_icon === true ? 'icon-entity' : ''}" style="${entityStyles(entity.styles, entity.stateObj, hass)}" @click="${onClick}">${renderValue(entity, hass)}</div>`;
}

export const clickHandler = (entity: string, actionConfig: ActionConfig, hass: HomeAssistant, element: LitElement) => {
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ export default class RoomCard extends LitElement {

try {
return html`
<ha-card elevation="2" style="${entityStyles(this.entity?.styles)}">
<ha-card elevation="2" style="${entityStyles(this.entity?.styles, this.stateObj, this._hass)}">
<div class="card-header">
${renderTitle(this.entity, this.config, this._hass, this)}
<div class="entities-info-row">
${this.info_entities.map((entity) => renderInfoEntity(entity, this._hass, this))}
</div>
</div>
${this.rows !== undefined && this.rows.length > 0 ?
renderRows(this.config, this.rows, this._hass, this) :
renderRows(this.rows, this._hass, this) :
renderEntitiesRow(this.config, this.entities, this._hass, this)}
${this._refCards}
</ha-card>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/format_number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FrontendLocaleData } from 'custom-card-helpers';
import { FormattingOptions, HomeAssistantEntity } from '../types/room-card-types';
import { NumberFormat } from './constants';

export const round = (value: number, precision = 2) => Math.round(value * 10 ** precision) / 10 ** precision;
export const round = (value?: number, precision = 2) => Math.round(value * 10 ** precision) / 10 ** precision;

export const isNumericState = (stateObj: HomeAssistantEntity) =>
!!stateObj.attributes.unit_of_measurement || !!stateObj.attributes.state_class;
Expand Down
18 changes: 16 additions & 2 deletions src/template.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/ban-types */
import { HomeAssistant } from "custom-card-helpers";
import { HomeAssistantEntity, RoomCardConfig, RoomCardEntity, RoomCardIcon } from "./types/room-card-types";
import { HomeAssistantEntity, RoomCardAttributeTemplate, RoomCardConfig, RoomCardEntity, RoomCardIcon } from "./types/room-card-types";
import { evalTemplate } from "./util";

// eslint-disable-next-line @typescript-eslint/ban-types
export const templateStyling = (stateObj: HomeAssistantEntity, config: RoomCardEntity | RoomCardConfig, hass: HomeAssistant) : Function => {
const icon = (config.icon as RoomCardIcon);

Expand All @@ -20,4 +20,18 @@ export const mapTemplate = (entity: RoomCardEntity, config: RoomCardConfig) => {
}

return entity;
}

export const getTemplateOrAttribute = (attribute: string | number | RoomCardAttributeTemplate | boolean, hass: HomeAssistant, stateObj: HomeAssistantEntity) => {
if(!attribute) {
return attribute;
}

if(typeof attribute == "object") {
if('template' in attribute) {
return evalTemplate(hass, stateObj, (attribute as RoomCardAttributeTemplate).template);
}
}

return attribute;
}
13 changes: 8 additions & 5 deletions src/types/room-card-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ActionConfig, HomeAssistant, LovelaceCardConfig } from 'custom-card-hel
import { HassEntity } from 'home-assistant-js-websocket';

export interface RoomCardEntity {
name?: string;
name?: string | RoomCardAttributeTemplate;
entity?: string;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
Expand All @@ -18,7 +18,7 @@ export interface RoomCardEntity {
stateObj: HomeAssistantEntity;
attribute?: string;
show_state?: boolean;
styles?: EntityStyles;
styles?: EntityStyles | RoomCardAttributeTemplate;
icon?: string | RoomCardIcon;
template?: string;
}
Expand All @@ -39,9 +39,8 @@ export interface RoomCardConfig extends LovelaceCardConfig {
icon?: string | RoomCardIcon;
rows?: RoomCardRow[];
show_icon?: boolean;
title?: string;
name?: string;
styles?: EntityStyles;
title?: string | RoomCardAttributeTemplate;
styles?: EntityStyles | RoomCardAttributeTemplate;
templates?: RoomCardTemplateContainer[];
content_alignment?: RoomCardAlignment;
}
Expand Down Expand Up @@ -107,4 +106,8 @@ export interface RoomCardTemplateDefinition {

export interface RoomCardLovelaceCardConfig extends LovelaceCardConfig {
hide_if?: HideIfConfig;
}

export interface RoomCardAttributeTemplate {
template: string;
}
42 changes: 22 additions & 20 deletions tests/entity/entityIcon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,27 +82,29 @@ describe('Testing entity file function entityIcon', () => {

expect(entityIcon(stateObj, config, hass)).toBe('mdi:on-icon');
}),
test.each`
entity_id
${'light.test_entity'}
${'switch.test_entity'}
${'binary_sensor.test_entity'}
${'input_boolean.test_entity'}
`('Passing config with icon state_on/state_off should return state_off icon', ({entity_id}) => {

stateObj.entity_id = entity_id;// 'input_boolean.test_entity';
stateObj.state = 'off';
const config: RoomCardConfig = {
entityIds: [],
type: '',
show_icon: true,
icon: {
state_on: 'mdi:on-icon',
state_off: 'mdi:off-icon',
}
};


[
['light.test_entity'],
['switch.test_entity'],
['binary_sensor.test_entity'],
['input_boolean.test_entity']
].forEach(([entity_id]) => {
test(`Passing config with icon and entity_id ${entity_id} state_on/state_off should return state_off icon`, () => {
stateObj.entity_id = entity_id;// 'input_boolean.test_entity';
stateObj.state = 'off';
const config: RoomCardConfig = {
entityIds: [],
type: '',
show_icon: true,
icon: {
state_on: 'mdi:on-icon',
state_off: 'mdi:off-icon',
}
};

expect(entityIcon(stateObj, config, hass)).toBe('mdi:off-icon');
expect(entityIcon(stateObj, config, hass)).toBe('mdi:off-icon');
})
}),
test('Passing config with icon iconditions equals should return condition', () => {

Expand Down
30 changes: 26 additions & 4 deletions tests/entity/entityName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ import { HassEntity } from 'home-assistant-js-websocket';
import { entityName } from '../../src/entity';
import { RoomCardEntity } from '../../src/types/room-card-types';
import { StubHassEntity } from '../testdata';
import { HomeAssistant } from 'custom-card-helpers';

describe('Testing entity file function computeEntity', () => {
const hass = createMock<HomeAssistant>();
test('Passing RoomCardEntity with name should return entity name', () => {
const entity : RoomCardEntity = {
name: 'Test entity',
stateObj: StubHassEntity
};
expect(entityName(entity)).toBe(entity.name);
expect(entityName(entity, hass)).toBe(entity.name);
}),
test('Passing RoomCardEntity with entity should return friendly name', () => {
StubHassEntity.attributes.friendly_name = 'Test Entity Friendly'
const entity : RoomCardEntity = {
entity: 'sensor.test_entity',
stateObj: StubHassEntity
};
expect(entityName(entity)).toBe(StubHassEntity.attributes.friendly_name);
expect(entityName(entity, hass)).toBe(StubHassEntity.attributes.friendly_name);
}),
test('Passing RoomCardEntity with entity should return entity_id', () => {
const hassEntity = createMock<HassEntity>();
Expand All @@ -27,14 +29,34 @@ describe('Testing entity file function computeEntity', () => {
entity: 'sensor.test_entity',
stateObj: hassEntity
};
expect(entityName(entity)).toBe('test_entity_id');
expect(entityName(entity, hass)).toBe('test_entity_id');
}),
test('Passing RoomCardEntity with no config should return null', () => {
const hassEntity = createMock<HassEntity>();
const entity : RoomCardEntity = {
stateObj: hassEntity
};
expect(entityName(entity)).toBe(null);
expect(entityName(entity, hass)).toBe(null);
}),

[
['off', 'Name off'],
['on', 'Name on']
].forEach(([state, expected]) => {
test(`Passing RoomCardEntity with entity state ${state} and name template should return ${expected}`, ()=>{
const hassEntity = createMock<HassEntity>();
hassEntity.entity_id = 'sensor.test_entity_id';
hassEntity.state = state;
const entity : RoomCardEntity = {
entity: 'sensor.test_entity',
stateObj: hassEntity,
name: {
template: "if (entity.state == 'on') return 'Name on'; else return 'Name off'; "
}
};

expect(entityName(entity, hass)).toBe(expected);
})
})
})

22 changes: 19 additions & 3 deletions tests/entity/entityStyles.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { HomeAssistant } from 'custom-card-helpers';
import { createMock } from 'ts-auto-mock';
import { entityStyles } from '../../src/entity';
import { EntityStyles } from '../../src/types/room-card-types';
import { EntityStyles, HomeAssistantEntity, RoomCardAttributeTemplate } from '../../src/types/room-card-types';

describe('Testing entity file function computeEntity', () => {
test('Passing entity_id should return entity name', () => {
const stateObj = createMock<HomeAssistantEntity>();
const hass = createMock<HomeAssistant>();

test('Passing styles object should return style string', () => {
const styles: EntityStyles = {
color: 'red',
height: '100'
}
expect(entityStyles(styles)).toBe('color: red;height: 100;');
expect(entityStyles(styles, stateObj, hass)).toBe('color: red;height: 100;');
}),
test.each`
state | expected
${'off'} ${'color: red'}
${'on'} ${'color: blue'}
`('Passing RoomCardAttributeTemplate should return style string', ({ state, expected }) => {
stateObj.state = state;
const template: RoomCardAttributeTemplate = {
template: "if (entity.state == 'off') return 'color: red'; else return 'color: blue';"
}
expect(entityStyles(template, stateObj, hass)).toBe(expected);
})
})
Loading

0 comments on commit 1ad9729

Please sign in to comment.