diff --git a/custom_components/cookcli/www/cookcli-card.js b/custom_components/cookcli/www/cookcli-card.js index 2f44bdd..2bacada 100644 --- a/custom_components/cookcli/www/cookcli-card.js +++ b/custom_components/cookcli/www/cookcli-card.js @@ -52,17 +52,12 @@ class CookCliCard extends HTMLElement { } /** - * Éditeur de configuration automatique intégré à Home Assistant. - * HA utilise ce schéma pour générer le formulaire visuel. - */ - static getConfigForm() { - return { - schema: [ - { name: "title", selector: { text: {} }, label: "Titre" }, - { name: "dashboard_path", selector: { text: {} }, label: "Chemin du dashboard", helper: "Laisser vide pour le dashboard courant" }, - { name: "entry_id", selector: { text: {} }, label: "Entry ID", helper: "Optionnel — si plusieurs serveurs CookCLI" }, - ], - }; + * Éditeur visuel : élément dédié (voir CookCliCardEditor plus bas) plutôt + * que getConfigForm(), dont le schéma est statique et n'a pas accès à + * hass — or la liste des dashboards disponibles en dépend. + */ + static getConfigElement() { + return document.createElement("cookcli-card-editor"); } set hass(hass) { @@ -248,7 +243,135 @@ class CookCliCard extends HTMLElement { } } +/** + * Éditeur visuel de la carte (ha-form + sélecteurs natifs). + * + * "Chemin du dashboard" propose les dashboards Lovelace du serveur, lus dans + * hass.panels (accessible à tous les utilisateurs, contrairement à + * lovelace/dashboards/list qui demande les droits admin). custom_value reste + * activé pour pouvoir saisir un chemin qui n'apparaît pas dans la liste. + */ +const COOKCLI_CARD_EDITOR_FIELDS = ["title", "dashboard_path", "entry_id"]; + +const COOKCLI_CARD_EDITOR_LABELS = { + title: "Titre", + dashboard_path: "Dashboard des recettes", + entry_id: "Serveur CookCLI", +}; + +const COOKCLI_CARD_EDITOR_HELPERS = { + title: "Titre de la carte. Par défaut : « Recettes ».", + dashboard_path: + "Seulement si la carte n'est pas sur le dashboard qui contient les vues recettes. Vide = dashboard courant.", + entry_id: + "Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.", +}; + +class CookCliCardEditor extends HTMLElement { + constructor() { + super(); + this._config = {}; + this._hass = undefined; + this._form = null; + this._optionsSignature = null; + } + + setConfig(config) { + this._config = config || {}; + this._update(); + } + + set hass(hass) { + this._hass = hass; + this._update(); + } + + connectedCallback() { + this._ensureForm(); + this._update(); + } + + _dashboardOptions() { + const panels = (this._hass && this._hass.panels) || {}; + return Object.values(panels) + .filter((p) => p.component_name === "lovelace" && p.url_path) + .map((p) => ({ + value: p.url_path, + label: p.title ? `${p.title} (${p.url_path})` : p.url_path, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + _buildSchema(options) { + return [ + { name: "title", selector: { text: {} } }, + { + name: "dashboard_path", + selector: { select: { options, custom_value: true, mode: "dropdown" } }, + }, + { name: "entry_id", selector: { config_entry: { integration: "cookcli" } } }, + ]; + } + + _ensureForm() { + if (this._form) return; + const form = document.createElement("ha-form"); + const options = this._dashboardOptions(); + this._optionsSignature = JSON.stringify(options); + form.schema = this._buildSchema(options); + form.computeLabel = (schema) => COOKCLI_CARD_EDITOR_LABELS[schema.name] || schema.name; + form.computeHelper = (schema) => COOKCLI_CARD_EDITOR_HELPERS[schema.name] || ""; + form.addEventListener("value-changed", (ev) => this._valueChanged(ev)); + this.appendChild(form); + this._form = form; + } + + // Formulaire créé une fois ; on ne met à jour que ses propriétés. Le schéma + // n'est reconstruit que si la liste des dashboards a changé (hass, lui, + // change en permanence). + _update() { + if (!this._form) return; + if (this._hass) { + this._form.hass = this._hass; + const options = this._dashboardOptions(); + const signature = JSON.stringify(options); + if (signature !== this._optionsSignature) { + this._optionsSignature = signature; + this._form.schema = this._buildSchema(options); + } + } + const data = {}; + for (const name of COOKCLI_CARD_EDITOR_FIELDS) { + if (this._config[name] !== undefined) data[name] = this._config[name]; + } + this._form.data = data; + } + + _valueChanged(ev) { + ev.stopPropagation(); + const value = ev.detail.value || {}; + + // Garde `type` et toute clé inconnue ; un champ vidé est supprimé. + const config = { ...this._config }; + for (const name of COOKCLI_CARD_EDITOR_FIELDS) { + const v = value[name]; + if (v === undefined || v === null || v === "") delete config[name]; + else config[name] = v; + } + + this._config = config; + this.dispatchEvent( + new CustomEvent("config-changed", { + detail: { config }, + bubbles: true, + composed: true, + }) + ); + } +} + customElements.define("cookcli-card", CookCliCard); +customElements.define("cookcli-card-editor", CookCliCardEditor); window.customCards = window.customCards || []; window.customCards.push({ @@ -256,4 +379,4 @@ window.customCards.push({ name: "CookCLI Recipes", preview: false, description: "Liste navigable de tes recettes CookCLI.", -}); +}); \ No newline at end of file diff --git a/custom_components/cookcli/www/cookcli-recipe-strategy.js b/custom_components/cookcli/www/cookcli-recipe-strategy.js index 94ad661..5beb278 100644 --- a/custom_components/cookcli/www/cookcli-recipe-strategy.js +++ b/custom_components/cookcli/www/cookcli-recipe-strategy.js @@ -41,7 +41,8 @@ * - custom:mod-card (card-mod) : force le ratio 30/70 des colonnes d'étape * - custom:cookcli-card : la liste (voir cookcli-card.js) * - * Configuration du tableau de bord (remplace le contenu YAML du dashboard) : + * Configuration du tableau de bord (éditable aussi via l'éditeur visuel, + * voir CookcliStrategyEditor ; sinon remplace le contenu YAML du dashboard) : * strategy: * type: custom:cookcli * title: Recettes @@ -405,77 +406,111 @@ class CookCliRecipeViewStrategy extends HTMLElement { } } +/** + * Éditeur visuel de la dashboard strategy (remplace l'édition YAML). + * + * Basé sur + sélecteurs natifs HA : mêmes composants, même rendu + * que les éditeurs des cartes officielles (thème, sélecteurs d'entités, + * traductions). Aucune dépendance Lit : on manipule directement l'élément. + * + * Contrat HA pour un éditeur de strategy : + * - setConfig(config) → reçoit la config courante + * - set hass(hass) → reçoit l'objet hass (mis à jour très souvent) + * - événement "config-changed" { detail: { config } } → sauvegarde + */ +const COOKCLI_EDITOR_SCHEMA = [ + { name: "title", selector: { text: {} } }, + { name: "timer_entity", selector: { entity: { domain: "timer" } } }, + { name: "step_entity", selector: { entity: { domain: "input_number" } } }, + { name: "entry_id", selector: { config_entry: { integration: "cookcli" } } }, +]; + +const COOKCLI_EDITOR_LABELS = { + title: "Titre du dashboard", + timer_entity: "Minuteur", + step_entity: "Étape en cours", + entry_id: "Serveur CookCLI", +}; + +const COOKCLI_EDITOR_HELPERS = { + title: "Titre de la liste et du dashboard. Par défaut : « Recettes ».", + timer_entity: + "Aide « Minuteur » partagée entre toutes les recettes (ex. timer.recette_en_cours). Vide = pas de boutons de minuteur.", + step_entity: + "Aide « Nombre » qui pilote l'onglet actif (ex. input_number.recette_etape). Vide = pas de boutons Commencer / Précédent / Suivant.", + entry_id: + "Seulement si tu as plusieurs serveurs CookCLI. Vide = le serveur par défaut.", +}; + class CookcliStrategyEditor extends HTMLElement { + constructor() { + super(); + this._config = {}; + this._hass = undefined; + this._form = null; + } + setConfig(config) { - this._config = config; + this._config = config || {}; + this._update(); } set hass(hass) { this._hass = hass; + this._update(); } - // Appelé automatiquement quand l'élément est inséré dans le DOM connectedCallback() { - this._render(); + this._ensureForm(); + this._update(); } - _render() { - console.log("Editor render appelé", this._config, this._hass); + _ensureForm() { + if (this._form) return; + const form = document.createElement("ha-form"); + form.schema = COOKCLI_EDITOR_SCHEMA; + form.computeLabel = (schema) => COOKCLI_EDITOR_LABELS[schema.name] || schema.name; + form.computeHelper = (schema) => COOKCLI_EDITOR_HELPERS[schema.name] || ""; + form.addEventListener("value-changed", (ev) => this._valueChanged(ev)); + this.appendChild(form); + this._form = form; + } - // On attend d'avoir hass avant de rendre : ha-input en a besoin - // pour son thème et ses styles. - if (!this._hass) { - this.innerHTML = `
Chargement…
`; - return; + // Le formulaire est créé une seule fois ; on ne met à jour que ses + // propriétés (pas de re-rendu du DOM à chaque changement de hass, ce qui + // ferait perdre le focus pendant la saisie). + _update() { + if (!this._form) return; + if (this._hass) this._form.hass = this._hass; + const data = {}; + for (const { name } of COOKCLI_EDITOR_SCHEMA) { + if (this._config[name] !== undefined) data[name] = this._config[name]; + } + this._form.data = data; + } + + _valueChanged(ev) { + ev.stopPropagation(); + const value = ev.detail.value || {}; + + // On part de la config existante (garde `type: custom:cookcli` et toute + // clé inconnue) et on ne touche qu'aux champs de l'éditeur. Un champ vidé + // est supprimé plutôt que stocké comme "". + const config = { ...this._config }; + for (const { name } of COOKCLI_EDITOR_SCHEMA) { + const v = value[name]; + if (v === undefined || v === null || v === "") delete config[name]; + else config[name] = v; } - const c = this._config || {}; - - // Remplacer temporairement ha-textfield par des inputs natifs - this.innerHTML = ` -
- - - - - - - -
- `; - - // Écouteurs natifs (change au lieu de input pour éviter les re-rendus intempestifs) - this.querySelectorAll("[data-config-key]").forEach((champ) => { - champ.addEventListener("change", (ev) => { - const cle = ev.target.dataset.configKey; - const nouvelleConfig = { ...this._config, [cle]: ev.target.value }; - this._config = nouvelleConfig; - }); - }); + this._config = config; + this.dispatchEvent( + new CustomEvent("config-changed", { + detail: { config }, + bubbles: true, + composed: true, + }) + ); } }