Refactors view creation to support sections

Updates view creation to leverage sections for improved layout control, especially on the home view.
The home view now uses a grid layout via sections.
This change makes it easier to create complex layouts and improves responsiveness.
Previously, the card creation logic was embedded directly within each view, leading to code duplication and difficulty in customizing layouts.

The changes introduce the concept of sections, which are distinct areas within a view that can contain multiple cards.
The home view is updated to use sections for the persons, areas, and quick access cards.
This makes it easier to create responsive layouts that adapt to different screen sizes.
This commit is contained in:
Ferry Cools
2025-08-05 12:28:21 +02:00
parent 2525869bc4
commit 421de8cec7
5 changed files with 139 additions and 101 deletions

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@ import {
ViewInfo,
} from './types/strategy/strategy-generics';
import { sanitizeClassName } from './utilities/auxiliaries';
import { logMessage, lvlError, lvlInfo } from './utilities/debug';
import { logMessage, lvlError } from './utilities/debug';
import RegistryFilter from './utilities/RegistryFilter';
import { stackHorizontal } from './utilities/cardStacking';
import { PersistentNotification } from './utilities/PersistentNotification';
@@ -57,13 +57,8 @@ class MushroomStrategy extends HTMLTemplateElement {
const moduleName = sanitizeClassName(`${viewName}View`);
const View = (await import(`./views/${moduleName}`)).default;
const currentView = new View(Registry.strategyOptions.views[viewName]);
const viewConfiguration = await currentView.getView();
if (viewConfiguration.cards.length) {
return viewConfiguration;
}
logMessage(lvlInfo, `View ${viewName} has no entities available!`);
return await currentView.getView();
} catch (e) {
logMessage(lvlError, `Error importing ${viewName} view!`, e);
}

View File

@@ -26,7 +26,7 @@ export function stackHorizontal(
defaultCount: number = 2,
columnCounts?: {
[key: string]: number | undefined;
},
}
): LovelaceCardConfig[] {
if (cardConfigurations.length <= 1) {
return cardConfigurations;

View File

@@ -11,6 +11,7 @@ import { sanitizeClassName } from '../utilities/auxiliaries';
import { logMessage, lvlFatal } from '../utilities/debug';
import RegistryFilter from '../utilities/RegistryFilter';
import { stackHorizontal } from '../utilities/cardStacking';
import { LovelaceSectionRawConfig } from '../types/homeassistant/data/lovelace/config/section';
/**
* Abstract View Class.
@@ -34,10 +35,6 @@ abstract class AbstractView {
type: '',
};
protected get domain(): SupportedDomains | 'home' {
return (this.constructor as unknown as ViewConstructor).domain;
}
/**
* Class constructor.
*
@@ -50,10 +47,40 @@ abstract class AbstractView {
}
}
protected get domain(): SupportedDomains | 'home' {
return (this.constructor as unknown as ViewConstructor).domain;
}
// noinspection JSUnusedGlobalSymbols Methodd is dynamically called.
/**
* Get a view configuration.
*
* The configuration includes the card configurations which are created by createCardConfigurations().
*/
async getView(): Promise<LovelaceViewConfig | false> {
const sectionsCards = await this.createSections();
if (!sectionsCards.length) {
return false;
}
if (this.domain === 'home') {
return {
...this.baseConfiguration,
sections: sectionsCards,
};
}
return {
...this.baseConfiguration,
cards: sectionsCards as LovelaceCardConfig[],
};
}
/**
* Create the configuration of the cards to include in the view.
*/
protected async createCardConfigurations(): Promise<LovelaceCardConfig[]> {
protected async createSections(): Promise<LovelaceSectionRawConfig[]> {
const viewCards: LovelaceCardConfig[] = [];
const moduleName = sanitizeClassName(this.domain + 'Card');
const DomainCard = (await import(`../cards/${moduleName}`)).default;
@@ -82,8 +109,8 @@ abstract class AbstractView {
// Create a card configuration for each entity in the current area.
areaCards.push(
...areaEntities.map((entity) =>
new DomainCard(entity, Registry.strategyOptions.card_options?.[entity.entity_id]).getCard(),
),
new DomainCard(entity, Registry.strategyOptions.card_options?.[entity.entity_id]).getCard()
)
);
// Stack the cards of the current area.
@@ -91,7 +118,7 @@ abstract class AbstractView {
areaCards = stackHorizontal(
areaCards,
Registry.strategyOptions.domains[this.domain as SupportedDomains].stack_count ??
Registry.strategyOptions.domains['_'].stack_count,
Registry.strategyOptions.domains['_'].stack_count
);
// Create and insert a Header card.
@@ -113,29 +140,6 @@ abstract class AbstractView {
return viewCards;
}
/**
* Get a view configuration.
*
* The configuration includes the card configurations which are created by createCardConfigurations().
*/
async getView(): Promise<LovelaceViewConfig> {
return {
...this.baseConfiguration,
cards: await this.createCardConfigurations(),
};
}
/**
* Get the domain's entity ids to target for a HASS service call.
*/
private getDomainTargets(): HassServiceTarget {
return {
entity_id: Registry.entities
.filter((entity) => entity.entity_id.startsWith(this.domain + '.'))
.map((entity) => entity.entity_id),
};
}
/**
* Initialize the view configuration with defaults and custom settings.
*
@@ -146,7 +150,7 @@ abstract class AbstractView {
protected initializeViewConfig(
viewConfiguration: ViewConfig,
customConfiguration: ViewConfig = {},
headerCardConfig: CustomHeaderCardConfig,
headerCardConfig: CustomHeaderCardConfig
): void {
this.baseConfiguration = { ...this.baseConfiguration, ...viewConfiguration, ...customConfiguration };
@@ -162,6 +166,17 @@ abstract class AbstractView {
...headerCardConfig,
}).createCard();
}
/**
* Get the domain's entity ids to target for a HASS service call.
*/
private getDomainTargets(): HassServiceTarget {
return {
entity_id: Registry.entities
.filter((entity) => entity.entity_id.startsWith(this.domain + '.'))
.map((entity) => entity.entity_id),
};
}
}
export default AbstractView;

View File

@@ -1,8 +1,6 @@
// noinspection JSUnusedGlobalSymbols Class is dynamically imported.
import { Registry } from '../Registry';
import { ActionConfig } from '../types/homeassistant/data/lovelace/config/action';
import { LovelaceCardConfig } from '../types/homeassistant/data/lovelace/config/card';
import { AreaCardConfig, StackCardConfig } from '../types/homeassistant/panels/lovelace/cards/types';
import { PersonCardConfig } from '../types/lovelace-mushroom/cards/person-card-config';
import { TemplateCardConfig } from '../types/lovelace-mushroom/cards/template-card-config';
@@ -13,9 +11,13 @@ import { logMessage, lvlError, lvlInfo } from '../utilities/debug';
import { localize } from '../utilities/localize';
import AbstractView from './AbstractView';
import registryFilter from '../utilities/RegistryFilter';
import { stackHorizontal } from '../utilities/cardStacking';
import { LovelaceViewConfig } from '../types/homeassistant/data/lovelace/config/view';
import { LovelaceBadgeConfig } from '../types/homeassistant/data/lovelace/config/badge';
import { LovelaceSectionRawConfig } from '../types/homeassistant/data/lovelace/config/section';
import { ActionConfig } from '../types/homeassistant/data/lovelace/config/action';
import HeaderCard from '../cards/HeaderCard';
import { stackHorizontal } from '../utilities/cardStacking';
import { LovelaceCardConfig } from '../types/homeassistant/data/lovelace/config/card';
/**
* Home View Class.
@@ -41,8 +43,8 @@ class HomeView extends AbstractView {
// TODO: Move type and max_columns to the abstract class.
static getDefaultConfig(): ViewConfig {
return {
//type: 'sections',
//max_columns: 4,
type: 'sections',
max_columns: 3,
header: {
badges_position: 'top',
layout: 'center',
@@ -60,38 +62,8 @@ class HomeView extends AbstractView {
* The configuration includes the card configurations which are created by createCardConfigurations().
*/
async getView(): Promise<LovelaceViewConfig> {
return {
...this.baseConfiguration,
badges: await this.createBadgeSection(),
cards: await this.createCardConfigurations(),
};
}
/**
* Create the configuration of the cards to include in the view.
*
* @override
*/
async createCardConfigurations(): Promise<LovelaceCardConfig[]> {
const homeViewCards: LovelaceCardConfig[] = [];
let personsSection, areasSection;
try {
[personsSection, areasSection] = await Promise.all([this.createPersonsSection(), this.createAreasSection()]);
} catch (e) {
logMessage(lvlError, 'Error importing created sections!', e);
return homeViewCards;
}
if (personsSection) {
homeViewCards.push(personsSection);
}
// Create the greeting section.
if (!Registry.strategyOptions.home_view.hidden.includes('greeting')) {
homeViewCards.push({
if (this.baseConfiguration.header && !Registry.strategyOptions.home_view.hidden.includes('greeting')) {
this.baseConfiguration.header.card = {
type: 'custom:mushroom-template-card',
primary: `{% set time = now().hour %}
{% if (time >= 18) %}
@@ -113,29 +85,78 @@ class HomeView extends AbstractView {
hold_action: {
action: 'none',
} as ActionConfig,
} as TemplateCardConfig);
} as TemplateCardConfig;
}
if (Registry.strategyOptions.quick_access_cards) {
homeViewCards.push(...Registry.strategyOptions.quick_access_cards);
return {
...this.baseConfiguration,
badges: await this.createBadgeSection(),
sections: await this.createSections(),
};
}
if (areasSection) {
homeViewCards.push(areasSection);
}
/**
* Create the configuration of the cards to include in the view.
*
* @override
*/
async createSections(): Promise<LovelaceSectionRawConfig[]> {
const MEDIA_QUERY = {
SMALL: '(max-width: 1343px)',
LARGE: '(min-width: 1344px)',
};
const sections: LovelaceSectionRawConfig[] = [];
if (Registry.strategyOptions.extra_cards) {
homeViewCards.push(...Registry.strategyOptions.extra_cards);
}
const addSection = (title: string, cards: LovelaceCardConfig[], mediaQuery?: string) => {
const section: LovelaceSectionRawConfig = {
type: 'grid',
/*title: title,*/ // TODO: Property is deprecated.
cards: cards,
};
return [
if (mediaQuery) {
section.visibility = [
{
type: 'vertical-stack',
cards: homeViewCards,
condition: 'screen',
media_query: mediaQuery,
},
];
}
sections.push(section);
};
try {
const [personCards, areaCards] = await Promise.all([this.createPersonCards(), this.createAreaCards()]);
const sectionConfigurations = [
['Persons', [personCards], MEDIA_QUERY.SMALL, !!personCards],
['Quick Access Wide', Registry.strategyOptions.quick_access_cards, MEDIA_QUERY.LARGE, true],
[
'Persons and Areas',
[personCards, areaCards].filter(Boolean),
MEDIA_QUERY.LARGE,
!!(personCards || areaCards),
],
['Quick Access Narrow', Registry.strategyOptions.quick_access_cards, MEDIA_QUERY.SMALL, true],
['Areas', [areaCards], MEDIA_QUERY.SMALL, !!areaCards],
['Extra', Registry.strategyOptions.extra_cards, undefined, true],
] as const;
sectionConfigurations.forEach(([title, cards, mediaQuery, condition]) => {
if (condition && cards.length) {
addSection(title, cards as LovelaceCardConfig[], mediaQuery);
return;
}
logMessage(lvlInfo, `Section ${title} has no entities available.`);
});
} catch (e) {
logMessage(lvlError, 'Error importing section cards!', e);
}
return sections;
}
/**
* Create a badge section to include in the view.
*
@@ -205,10 +226,9 @@ class HomeView extends AbstractView {
*
* If the section is marked as hidden in the strategy option, then the section is not created.
*/
private async createPersonsSection(): Promise<StackCardConfig | undefined> {
private async createPersonCards(): Promise<StackCardConfig | undefined> {
if (Registry.strategyOptions.home_view.hidden.includes('persons')) {
// The section is hidden.
logMessage(lvlInfo, 'Persons section is hidden.');
return;
}
@@ -221,8 +241,13 @@ class HomeView extends AbstractView {
.map((person) => new PersonCard(person).getCard())
);
cardConfigurations.push(...cardConfigurations);
return {
type: 'vertical-stack',
grid_options: {
columns: 'full',
},
cards: stackHorizontal(
cardConfigurations,
Registry.strategyOptions.home_view.stack_count['persons'] ?? Registry.strategyOptions.home_view.stack_count['_']
@@ -236,9 +261,9 @@ class HomeView extends AbstractView {
* Area cards are grouped into two areas per row.
* If the section is marked as hidden in the strategy option, then the section is not created.
*/
private async createAreasSection(): Promise<StackCardConfig | undefined> {
private async createAreaCards(): Promise<StackCardConfig | undefined> {
if (Registry.strategyOptions.home_view.hidden.includes('areas')) {
// Areas section is hidden.
logMessage(lvlInfo, 'Areas section is hidden.');
return;
}
@@ -269,9 +294,13 @@ class HomeView extends AbstractView {
);
}
if (!Registry.strategyOptions.home_view.hidden.includes('areasTitle')) {
cardConfigurations.unshift(new HeaderCard({}, { title: localize('generic.areas') }).createCard());
}
return {
type: 'vertical-stack',
title: Registry.strategyOptions.home_view.hidden.includes('areasTitle') ? undefined : localize('generic.areas'),
columns: 'full',
cards: stackHorizontal(cardConfigurations, Registry.strategyOptions.home_view.stack_count['_'], {
'custom:mushroom-template-card': Registry.strategyOptions.home_view.stack_count.areas?.[0],
area: Registry.strategyOptions.home_view.stack_count.areas?.[1],