Refactor configuration handling and improve product filtering

Refactor setConfig method for improved validation and normalization of configuration. Update fuzzy matching and rendering logic for inactive products.
This commit is contained in:
thekiwismarthome
2026-02-06 23:51:53 +13:00
committed by GitHub
parent 7cf703a9ac
commit 07329323bf
@@ -1,8 +1,5 @@
/** /**
* Shopping List Manager - Custom Lovelace Card * Shopping List Manager - Custom Lovelace Card
*
* Uses proper Home Assistant WebSocket API
* Version: 2.1.0 - Fixed syntax errors
*/ */
// Category definitions // Category definitions
@@ -112,16 +109,53 @@ class ShoppingListCard extends HTMLElement {
} }
setConfig(config) { setConfig(config) {
this._config = { ...config }; // shallow-copy: HA 2026+ freezes the original if (!config || typeof config !== 'object') {
// Derive a unique settings key for THIS card instance so each card on throw new Error('Invalid configuration');
// the dashboard keeps its own independent settings in localStorage. }
// Set `card_id` in YAML for an explicit stable label; otherwise title is used.
const id = (config.card_id || config.title || 'shopping_list').toString().trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'); // Normalize + freeze config shape here
this._settingsKey = `shopping_list_settings_${id}`; this._config = {
// Re-load settings now that we have the correct key title: config.title ?? 'Shopping List',
this._settings = this._loadSettings(); card_id: config.card_id,
products_per_row: config.products_per_row ?? 'auto',
layout: config.layout ?? 'grid',
haptics: config.haptics ?? 'medium',
hide_completed: !!config.hide_completed,
compact_headers: !!config.compact_headers
};
// Derive a stable per-card storage key
const idSource =
this._config.card_id ||
this._config.title ||
'shopping_list';
const id = idSource
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_');
const newSettingsKey = `shopping_list_settings_${id}`;
// If the card_id / title changed, reload settings
if (this._settingsKey !== newSettingsKey) {
this._settingsKey = newSettingsKey;
this._settings = this._loadSettings();
}
// First-time init
if (!this._settings) {
this._settings = this._loadSettings();
}
// Trigger a re-render if already attached
if (this.isConnected) {
this._render?.();
}
} }
/** /**
* Load data using Home Assistant's connection.sendMessagePromise * Load data using Home Assistant's connection.sendMessagePromise
*/ */
@@ -1110,15 +1144,17 @@ class ShoppingListCard extends HTMLElement {
* Fuzzy search - matches even with typos or partial matches * Fuzzy search - matches even with typos or partial matches
*/ */
_fuzzyMatch(text, query) { _fuzzyMatch(text, query) {
text = text.toLowerCase(); if (!text || !query) return false;
query = query.toLowerCase();
text = String(text).toLowerCase();
query = String(query).toLowerCase();
// Exact match or substring // Exact match or substring
if (text.includes(query)) { if (text.includes(query)) {
return true; return true;
} }
// Fuzzy match - allows for missing characters // Fuzzy match - characters in order
let queryIndex = 0; let queryIndex = 0;
for (let i = 0; i < text.length && queryIndex < query.length; i++) { for (let i = 0; i < text.length && queryIndex < query.length; i++) {
if (text[i] === query[queryIndex]) { if (text[i] === query[queryIndex]) {
@@ -1128,6 +1164,7 @@ class ShoppingListCard extends HTMLElement {
return queryIndex === query.length; return queryIndex === query.length;
} }
/** /**
* Filter products by search and split into active/inactive * Filter products by search and split into active/inactive
*/ */
@@ -1138,7 +1175,8 @@ class ShoppingListCard extends HTMLElement {
if (this._searchQuery) { if (this._searchQuery) {
const query = this._searchQuery.toLowerCase(); const query = this._searchQuery.toLowerCase();
products = products.filter(product => products = products.filter(product =>
this._fuzzyMatch(product.name, query) this._fuzzyMatch(product.name, query) ||
this._fuzzyMatch(product.category, query)
); );
} }
@@ -1185,6 +1223,8 @@ class ShoppingListCard extends HTMLElement {
*/ */
_getInactiveProducts() { _getInactiveProducts() {
const filtered = this._getFilteredProducts(); const filtered = this._getFilteredProducts();
// 🔑 When searching, show ALL inactive matches (including recently used)
return filtered.filter(product => { return filtered.filter(product => {
const qty = this._activeList[product.key]?.qty || 0; const qty = this._activeList[product.key]?.qty || 0;
return qty === 0; return qty === 0;
@@ -1660,22 +1700,36 @@ class ShoppingListCard extends HTMLElement {
} }
// Show inactive products in "Recently Used" section (unless hidden) // Show inactive products in "Recently Used" section (unless hidden)
if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) { if (inactiveProducts.length > 0 && !this._settings.hideCompleted) {
html += ` if (this._searchQuery) {
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--divider-color);"> // During search: just show matches, no header
<div style="font-size: 12px; color: var(--secondary-text-color); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500;"> const containerClass =
Recently Used this._settings.layout === 'list' ? 'product-list' : 'product-grid';
html += `
<div class="${containerClass}">
${inactiveProducts.map(p => this._renderProductTile(p)).join('')}
</div> </div>
${this._sortBy === 'category' ? `;
this._renderProductsByCategory(inactiveProducts, true) : } else {
`<div class="${this._settings.layout === 'list' ? 'product-list' : 'product-grid'}"> // Normal Recently Used section
${inactiveProducts.map(product => this._renderProductTile(product)).join('')} html += `
</div>` <div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--divider-color);">
} <div style="font-size: 12px; color: var(--secondary-text-color); margin-bottom: 12px; text-transform: uppercase;">
</div> Recently Used
`; </div>
${this._sortBy === 'category'
? this._renderProductsByCategory(inactiveProducts, true)
: `<div class="${this._settings.layout === 'list' ? 'product-list' : 'product-grid'}">
${inactiveProducts.map(p => this._renderProductTile(p)).join('')}
</div>`
}
</div>
`;
}
} }
// Empty state // Empty state
if (activeProducts.length === 0 && inactiveProducts.length === 0) { if (activeProducts.length === 0 && inactiveProducts.length === 0) {
html += ` html += `
@@ -1940,257 +1994,14 @@ class ShoppingListCard extends HTMLElement {
} }
} }
// Temporarily disabled GUI editor - use YAML configuration
// static getConfigElement() {
// return document.createElement('shopping-list-card-editor');
// }
static getStubConfig() {
return { title: 'Shopping List' };
}
getCardSize() { getCardSize() {
return 3; return 3;
} }
} }
// ── GUI Config Editor ─────────────────────────────────────────
// This is the standard HA card-editor pattern. HA renders this
// element inside its "configure card" panel automatically when the
// card exposes a static getConfigElement() method.
class ShoppingListCardEditor extends HTMLElement {
setConfig(config) {
console.log('[Editor] setConfig called, _rendered:', this._rendered, 'config:', config);
this._config = { ...(config || {}) }; // shallow-copy: HA 2026+ freezes the original
if (!this._rendered) {
console.log('[Editor] First render, calling _render()');
this._render(); // only render once, then STOP
} else {
console.log('[Editor] Already rendered, ignoring setConfig');
}
// After first render, ignore setConfig calls - the updateConfig closure
// already handles live updates by mutating this._config directly
}
set hass(hass) {
console.log('[Editor] hass setter called, _rendered:', this._rendered);
this._hass = hass;
if (!this._rendered) this._render();
}
_render() {
if (!this._config) return; // nothing to render yet
// Attach shadow DOM once, reuse on subsequent renders
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
const title = this._config.title || '';
const productsPerRow = String(this._config.products_per_row || '3');
const layout = this._config.layout || 'grid';
const haptics = this._config.haptics || 'medium';
const hideCompleted = !!this._config.hide_completed;
const compactHeaders = !!this._config.compact_headers;
this.shadowRoot.innerHTML = `
<style>
.row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
ha-text-field, ha-select {
display: block;
flex: 1;
min-width: 200px;
}
.toggle-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px 0;
gap: 12px;
}
.toggle-row + .toggle-row {
border-top: 1px solid var(--divider-color);
}
.toggle-text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toggle-label {
font-size: 14px;
color: var(--primary-text-color);
font-weight: 400;
}
.toggle-hint {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 2px;
}
.section-divider {
border: none;
border-top: 1px solid var(--divider-color);
margin: 8px 0 16px 0;
}
.code-editor-link {
display: block;
margin-top: 16px;
padding: 12px 0 0 0;
border-top: 1px solid var(--divider-color);
font-size: 14px;
color: var(--primary-color);
cursor: pointer;
background: none;
border-left: none;
border-right: none;
border-bottom: none;
text-align: left;
width: 100%;
}
.code-editor-link:hover { text-decoration: underline; }
.yaml-block {
background: var(--card-background-color);
border: 1px solid var(--divider-color);
border-radius: 6px;
padding: 12px;
margin-top: 8px;
font-family: monospace;
font-size: 13px;
color: var(--primary-text-color);
white-space: pre;
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
}
</style>
<!-- Title -->
<div class="row">
<ha-text-field id="ed-title" label="Title" placeholder="Shopping List"></ha-text-field>
</div>
<!-- Products Per Row -->
<div class="row">
<ha-select id="ed-perrow" label="Products Per Row">
<mwc-list-item value="auto">Auto</mwc-list-item>
<mwc-list-item value="2">2</mwc-list-item>
<mwc-list-item value="3">3</mwc-list-item>
<mwc-list-item value="4">4</mwc-list-item>
<mwc-list-item value="5">5</mwc-list-item>
</ha-select>
<!-- Layout -->
<ha-select id="ed-layout" label="Layout">
<mwc-list-item value="grid">Grid</mwc-list-item>
<mwc-list-item value="list">List</mwc-list-item>
</ha-select>
</div>
<!-- Haptic Feedback -->
<div class="row">
<ha-select id="ed-haptics" label="Haptic Feedback">
<mwc-list-item value="off">Off</mwc-list-item>
<mwc-list-item value="low">Low</mwc-list-item>
<mwc-list-item value="medium">Medium</mwc-list-item>
<mwc-list-item value="high">High</mwc-list-item>
</ha-select>
</div>
<hr class="section-divider">
<!-- Toggle: Hide completed items -->
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-label">Hide completed items</span>
<span class="toggle-hint">Hides the "Recently Used" section showing products not currently on the list.</span>
</div>
<ha-switch id="ed-hide-completed"></ha-switch>
</div>
<!-- Toggle: Compact headers -->
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-label">Compact headers</span>
<span class="toggle-hint">Show category names only (no emoji)</span>
</div>
<ha-switch id="ed-hide-headers"></ha-switch>
</div>
<!-- Show/hide YAML preview -->
<button class="code-editor-link" id="ed-code-toggle">
${this._showCodeEditor ? 'Hide code editor' : 'Show code editor'}
</button>
${this._showCodeEditor ? `<div class="yaml-block">${this._toYaml()}</div>` : ''}
`;
// ── Set values imperatively (reliable with shadow DOM + HA components) ──
const titleEl = this.shadowRoot.querySelector('#ed-title');
const perrowEl = this.shadowRoot.querySelector('#ed-perrow');
const layoutEl = this.shadowRoot.querySelector('#ed-layout');
const hapticsEl = this.shadowRoot.querySelector('#ed-haptics');
const hideCompletedEl = this.shadowRoot.querySelector('#ed-hide-completed');
const hideHeadersEl = this.shadowRoot.querySelector('#ed-hide-headers');
titleEl.value = title;
perrowEl.value = productsPerRow;
layoutEl.value = layout;
hapticsEl.value = haptics;
hideCompletedEl.checked = hideCompleted;
hideHeadersEl.checked = compactHeaders;
// ── Single updateConfig closure — mirrors your working pattern ──
const updateConfig = () => {
console.log('[Editor] updateConfig called');
this._config = {
...this._config,
title: titleEl.value || 'Shopping List',
products_per_row: perrowEl.value,
layout: layoutEl.value,
haptics: hapticsEl.value,
hide_completed: hideCompletedEl.checked,
compact_headers: hideHeadersEl.checked
};
console.log('[Editor] Dispatching config-changed:', this._config);
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: this._config },
bubbles: true,
composed: true
}));
};
titleEl.addEventListener('input', updateConfig);
perrowEl.addEventListener('value-changed', updateConfig);
layoutEl.addEventListener('value-changed', updateConfig);
hapticsEl.addEventListener('value-changed', updateConfig);
hideCompletedEl.addEventListener('checked-changed', updateConfig);
hideHeadersEl.addEventListener('checked-changed', updateConfig);
this.shadowRoot.querySelector('#ed-code-toggle').addEventListener('click', () => {
this._showCodeEditor = !this._showCodeEditor;
this._render();
});
this._rendered = true;
}
_toYaml() {
const lines = ['type: custom:shopping-list-card'];
if (this._config.title) lines.push('title: ' + this._config.title);
if (this._config.products_per_row) lines.push('products_per_row: ' + this._config.products_per_row);
if (this._config.layout) lines.push('layout: ' + this._config.layout);
if (this._config.haptics) lines.push('haptics: ' + this._config.haptics);
if (this._config.hide_completed) lines.push('hide_completed: true');
if (this._config.compact_headers) lines.push('compact_headers: true');
return lines.join('\n');
}
}
customElements.define('shopping-list-card-editor', ShoppingListCardEditor);
customElements.define('shopping-list-card', ShoppingListCard); customElements.define('shopping-list-card', ShoppingListCard);
window.customCards = window.customCards || []; window.customCards = window.customCards || [];