From d17bcc05fb3b7300f2e7b80f7679859231617675 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 07:53:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(devices):=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=C2=AB=D0=A1=D0=BA=D1=80=D1=8B=D1=82=D1=8C?= =?UTF-8?q?/=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=C2=BB=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B5=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20(#618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На вкладках «На плане» и «Скрытые» — флажки строк, «Выбрать все (N)» по всему отфильтрованному набору (включая строки за «Показать ещё»), панель «Выбрано: K · Скрыть/Показать выбранные (K) · Снять выбор» в отдельном контейнере .device-inbox-batch вне .device-inbox-filters. - src/device-inbox.ts: чистые inboxVisibilityAllowed (один источник для canHide/canShow и выбора), selectableInboxRows, effectiveInboxSelection, applyInboxVisibility — свёртка одиночных действий; одиночное = пакет из одной строки. - src/device-inbox-batch.ts (новый, lazy editor): выбор, панель, строка- флажок и writeInboxVisibility — одна запись houseplan/config/set с expected_rev на пакет, busy='__batch__' (inert), откат маркеров при отказе, выбор сохраняется и сужается. Одиночный _setInboxHidden рантайма — тонкая обёртка над той же записью; рантайм стал короче базы на 14 строк. - сброс выбора при смене вкладки (клик и стрелка), поиска и «Только новые». - i18n en/ru/de/fr: 9 ключей по ТЗ §8. - смок demo/smoke_device_inbox_batch.mjs (AC1–AC8, AC10), юниты test/device-inbox.test.mjs (AC2, AC4), 4 мутанта в реестре. - docs: USER-GUIDE ru/en, FILTERING.md, оба changelog. - monolith-baseline: hostRefs 4948→4959, bundleBytes 2500387→2505569 — новая фича (состояние выбора и запись идут через порт хоста). - bundle-budget: LAZY_EDITOR_GZIP_CEILING 245 400 → 246 600 (замер 245 610, +1 190 Б gzip от базы 244 420); initial View 289 449 → 289 765 (+316 Б, строки en-словаря), в пределах потолка. Golden device-inbox-* изменятся ожидаемо — пересъёмка предрелизным гейтом на Linux CI (§11.4). Issue: #618 User-Visible: yes --- demo/smoke_device_inbox_batch.mjs | 270 ++++++++++++++++++++++++++++++ docs/CHANGELOG.md | 7 + docs/CHANGELOG.ru.md | 7 + docs/FILTERING.md | 5 + docs/USER-GUIDE.md | 11 ++ docs/USER-GUIDE.ru.md | 12 ++ scripts/mutation-registry.mjs | 49 ++++++ src/device-inbox-batch.ts | 190 +++++++++++++++++++++ src/device-inbox.ts | 78 ++++++++- src/houseplan-card.ts | 2 + src/houseplan-editor-runtime.ts | 58 +++---- src/i18n/de.json | 9 + src/i18n/en.json | 9 + src/i18n/fr.json | 9 + src/i18n/ru.json | 9 + src/styles/dialogs.styles.ts | 29 ++++ test/device-inbox.test.mjs | 158 ++++++++++++++++- 17 files changed, 873 insertions(+), 39 deletions(-) create mode 100644 demo/smoke_device_inbox_batch.mjs create mode 100644 src/device-inbox-batch.ts diff --git a/demo/smoke_device_inbox_batch.mjs b/demo/smoke_device_inbox_batch.mjs new file mode 100644 index 00000000..b1e5f83e --- /dev/null +++ b/demo/smoke_device_inbox_batch.mjs @@ -0,0 +1,270 @@ +// #618: batch Hide/Show in the Devices catalog. Selection is read-only, a +// batch of K rows is exactly one `houseplan/config/set`, a failed write +// changes nothing and keeps the selection, and the selection lives only on +// the "On plan" / "Hidden" tabs. +import { launch, checkAll, finish } from './serve.mjs'; + +const { page, browser } = await launch({ width: 980, height: 820 }); +const out = await page.evaluate(async () => { + const c = window.__card; + const root = () => c.renderRoot || c.shadowRoot; + const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const settle = async () => { await c.updateComplete; await frame(); await c.updateComplete; }; + const result = {}; + + // Let the seeder and any debounced background write finish first. + const t0 = Date.now(); + while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) { + await new Promise((resolve) => setTimeout(resolve, 60)); + } + await new Promise((resolve) => setTimeout(resolve, 650)); + c._saveConfigDebounced?.flush?.(); + await c._writeChain; + + const realWS = c.hass.callWS; + let configWrites = 0; + let otherWrites = 0; + let gate = null; + let reject = null; + let lastSet = null; + // The demo backend acknowledges config/set without storing it; remember the + // last accepted document so a conflict re-read returns the real server state. + let accepted = null; + const countingWS = async (message) => { + if (message.type === 'houseplan/config/set') { + configWrites += 1; + lastSet = message; + if (gate) await gate.promise; + if (reject) { const error = reject; reject = null; throw error; } + const response = await realWS(message); + accepted = JSON.parse(JSON.stringify(message.config)); + return response; + } + if (message.type === 'houseplan/config/get' && accepted) { + const response = await realWS(message); + return { ...response, config: JSON.parse(JSON.stringify(accepted)) }; + } + if (/^houseplan\/(config|layout)\/(set|update|delete)$/.test(message.type)) { + otherWrites += 1; + } + return realWS(message); + }; + c.hass = { ...c.hass, callWS: countingWS }; + // A registry change arrives as a fresh hass object; keep the write counter. + const refreshHass = async () => { + c.hass = { ...window.__mkHass(), callWS: countingWS }; + await new Promise((resolve) => setTimeout(resolve, 220)); + await settle(); + }; + + c._setMode('devices'); + await settle(); + c._openDeviceInbox(); + await settle(); + + const tabButton = (index) => root().querySelectorAll('.device-inbox-tabs [role="tab"]')[index]; + const tabCount = (index) => Number(tabButton(index)?.querySelector('span')?.textContent || NaN); + const batch = () => root().querySelector('.device-inbox-batch'); + const selectAll = () => batch()?.querySelector('.device-inbox-select-all input'); + const rowBox = (binding) => root() + .querySelector(`.device-inbox-row[data-binding="${binding}"] .device-inbox-select input`); + const click = async (node) => { node?.click(); await settle(); }; + const selected = () => [...(c._deviceInbox?.selected || [])]; + const snapshot = () => JSON.stringify({ + config: c._serverCfg, layout: c._layout, cfgRev: c._cfgRev, layoutRev: c._layoutRev, + }); + + // ---- AC1: only "On plan" and "Hidden" carry selection --------------------- + const presence = []; + for (let index = 0; index < 4; index++) { + await click(tabButton(index)); + presence.push({ + tab: c._deviceInbox?.tab, + batch: !!batch(), + boxes: root().querySelectorAll('.device-inbox-select input').length, + rows: root().querySelectorAll('.device-inbox-row').length, + }); + } + const byTab = Object.fromEntries(presence.map((item) => [item.tab, item])); + result.batchOnlyOnTwoTabs = byTab.on_plan?.batch && byTab.on_plan.boxes === byTab.on_plan.rows + && byTab.on_plan.rows > 3 + && !byTab.available?.batch && byTab.available?.boxes === 0 + && !byTab.readd?.batch && byTab.readd?.boxes === 0 + && byTab.hidden?.batch === true; + await click(tabButton(0)); + // smoke_hidden_flag takes the LAST checkbox in .device-inbox-filters. + const filterBoxes = [...root().querySelectorAll('.device-inbox-filters input[type="checkbox"]')]; + result.batchLivesOutsideFilters = !root().querySelector('.device-inbox-filters .device-inbox-batch') + && !!filterBoxes.at(-1)?.closest('.device-inbox-filter-help'); + + // ---- AC8: selecting is read-only ----------------------------------------- + const before = snapshot(); + const onPlan = c._deviceInboxRows().filter((row) => row.category === 'on_plan' && row.canHide); + const [a, b, d, e] = onPlan.map((row) => row.binding); + await click(rowBox(a)); + await click(rowBox(b)); + result.rowSelectionWorks = selected().length === 2 && !!root().querySelector('.device-inbox-batch-actions') + && /Selected: 2/.test(batch()?.textContent || ''); + await click(selectAll()); + result.selectAllSelectsEveryEligibleRow = selected().length === onPlan.length + && new RegExp(`Select all \\(${onPlan.length}\\)`).test(batch()?.textContent || ''); + await click(root().querySelector('.device-inbox-batch-clear')); + result.clearSelectionEmpties = selected().length === 0 && !root().querySelector('.device-inbox-batch-actions'); + result.selectionIsReadOnly = snapshot() === before && configWrites === 0 && otherWrites === 0; + + // ---- AC6: selection resets on tab / arrow / search / New only ------------ + await click(rowBox(a)); + await click(tabButton(2)); + result.selectionResetOnTabClick = selected().length === 0; + await click(tabButton(0)); + await click(rowBox(a)); + root().querySelector('.device-inbox-tabs') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + await settle(); + result.selectionResetOnArrow = c._deviceInbox?.tab === 'available' && selected().length === 0; + await click(tabButton(0)); + await click(rowBox(a)); + const search = root().querySelector('.device-inbox-search'); + search.value = 'x'; + search.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + await settle(); + result.selectionResetOnSearch = selected().length === 0; + search.value = ''; + search.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + await settle(); + await click(rowBox(a)); + const onlyNew = root().querySelector('.device-inbox-filters input[type="checkbox"]'); + await click(onlyNew); + result.selectionResetOnOnlyNew = selected().length === 0; + if (c._deviceInbox?.onlyNew) await click(root().querySelector('.device-inbox-filters input[type="checkbox"]')); + + await click(rowBox(a)); + await click(rowBox(b)); + const nestedRow = c._deviceInboxRows().find((row) => row.binding === d); + c._openInboxMarker(nestedRow); + await settle(); + const nestedOpen = !!c._markerDialog && !c._deviceInbox; + c._closeMarkerDialog(); + await settle(); + result.selectionSurvivesNestedDialog = nestedOpen + && JSON.stringify(selected().sort()) === JSON.stringify([a, b].sort()) + && rowBox(a)?.checked === true; + await click(root().querySelector('.device-inbox-batch-clear')); + + // ---- AC3 + AC7: hide 3 in one write, catalog inert while writing ---------- + const hiddenBefore = tabCount(2); + const onPlanBefore = tabCount(0); + for (const binding of [a, b, d]) await click(rowBox(binding)); + const writesBeforeHide = configWrites; + let release; + gate = { promise: new Promise((resolve) => { release = resolve; }) }; + root().querySelector('.device-inbox-batch-apply').click(); + await c.updateComplete; + while (configWrites === writesBeforeHide) await new Promise((resolve) => setTimeout(resolve, 5)); + await c.updateComplete; + result.catalogInertWhileWriting = root().querySelector('.device-inbox')?.hasAttribute('inert') === true + && c._deviceInbox?.busy === '__batch__'; + gate = null; + release(); + while (c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10)); + await settle(); + const hideToast = c._toast; + const markerHidden = (binding) => (c._serverCfg.markers || []) + .filter((marker) => marker.binding === binding && !marker.removed); + result.hideIsOneWrite = configWrites - writesBeforeHide === 1 + && typeof lastSet?.expected_rev === 'number'; + result.hideAppliedToAll = [a, b, d].every((binding) => { + const live = markerHidden(binding); + return live.length === 1 && live[0].hidden === true; + }) && markerHidden(e).every((marker) => marker.hidden !== true); + result.hideToastCountsRows = hideToast === 'Hidden: 3'; + result.selectionClearedAfterSuccess = selected().length === 0 + && root().querySelector('.device-inbox')?.hasAttribute('inert') === false; + result.tabCountersUpdated = tabCount(2) === hiddenBefore + 3 && tabCount(0) === onPlanBefore - 3; + + // Show 2 of the 3 on "Hidden": total two writes, one of the three stays. + await click(tabButton(2)); + await click(rowBox(a)); + await click(rowBox(b)); + result.showButtonNamesCount = /Show selected \(2\)/.test(batch()?.textContent || ''); + root().querySelector('.device-inbox-batch-apply').click(); + await c.updateComplete; + while (c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10)); + await settle(); + result.showIsOneWrite = configWrites - writesBeforeHide === 2; + result.showToastCountsRows = c._toast === 'Shown: 2'; + const hiddenNow = c._deviceInboxRows().filter((row) => row.category === 'hidden').map((row) => row.binding); + result.oneOfThreeStaysHidden = hiddenNow.includes(d) && !hiddenNow.includes(a) && !hiddenNow.includes(b) + && markerHidden(a).length === 1 && markerHidden(a)[0].hidden === false; + + // ---- AC2: an HA-disabled row is listed but not selectable nor counted ---- + // (both tabs; the rows keep a marker, so HA deactivation keeps them listed) + const idOf = (binding) => binding.slice(binding.indexOf(':') + 1); + const selectAllCount = () => Number((batch()?.querySelector('.device-inbox-select-all')?.textContent || '') + .match(/\((\d+)\)/)?.[1] ?? NaN); + const inactiveChecks = []; + for (const [tabIndex, binding] of [[2, d], [0, a]]) { + await click(tabButton(tabIndex)); + const countBefore = selectAllCount(); + window.__setRegistryDisabled('device', idOf(binding), 'user'); + await refreshHass(); + const box = rowBox(binding); + inactiveChecks.push(!!box && box.disabled && !!box.closest('label')?.title + && selectAllCount() === countBefore - 1 + && c._deviceInboxRows().find((row) => row.binding === binding)?.status.kind === 'ha_disabled'); + await click(selectAll()); + inactiveChecks.push(!selected().includes(binding) && selected().length === countBefore - 1); + await click(root().querySelector('.device-inbox-batch-clear')); + window.__setRegistryDisabled('device', idOf(binding), null); + await refreshHass(); + } + result.inactiveRowNotSelectable = inactiveChecks.length === 4 && inactiveChecks.every(Boolean); + await click(tabButton(2)); + + // ---- AC5: a rejected write changes nothing and keeps the selection ------- + for (const [label, error] of [ + ['Error', new Error('batch rejected by test')], + ['Conflict', Object.assign(new Error('revision conflict'), { code: 'conflict' })], + ]) { + await click(rowBox(d)); + const markersBefore = JSON.stringify(c._serverCfg.markers); + const writes = configWrites; + reject = error; + root().querySelector('.device-inbox-batch-apply').click(); + await c.updateComplete; + while (configWrites === writes || c._deviceInbox?.busy) await new Promise((resolve) => setTimeout(resolve, 10)); + await settle(); + result[`rejected${label}KeepsMarkers`] = JSON.stringify(c._serverCfg.markers) === markersBefore + && markerHidden(d)[0]?.hidden === true; + result[`rejected${label}ShowsError`] = /^Error:|Ошибка|error/i.test(c._toast || '') + && c._toast !== 'Shown: 1'; + result[`rejected${label}KeepsSelection`] = JSON.stringify(selected()) === JSON.stringify([d]) + && rowBox(d)?.checked === true; + result[`rejected${label}CatalogActiveAgain`] = !c._deviceInbox?.busy + && root().querySelector('.device-inbox')?.hasAttribute('inert') === false; + await click(root().querySelector('.device-inbox-batch-clear')); + } + + // ---- AC10: no horizontal overflow on desktop ----------------------------- + await click(tabButton(0)); + await click(rowBox(e)); + const dialog = root().querySelector('hp-dialog'); + result.noHorizontalOverflow = !!dialog && dialog.scrollWidth <= dialog.clientWidth + 1; + return result; +}); + +// Narrow viewport: the selection panel wraps instead of overflowing. +await page.setViewportSize({ width: 390, height: 820 }); +out.noHorizontalOverflowNarrow = await page.evaluate(async () => { + const c = window.__card; + const root = () => c.renderRoot || c.shadowRoot; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await c.updateComplete; + const dialog = root().querySelector('hp-dialog'); + const inbox = root().querySelector('.device-inbox'); + return !!dialog && !!root().querySelector('.device-inbox-batch-actions') + && dialog.scrollWidth <= dialog.clientWidth + 1 && inbox.scrollWidth <= inbox.clientWidth + 1; +}); + +checkAll(out); +await finish(browser, out); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2c28ac7d..c248db1d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,13 @@ when it is picked, with a message naming the limit; for a raster image the card offers to upload a reduced copy instead ([#617](https://github.com/Matysh/houseplan-card/issues/617)). +- The **Devices** catalog can now hide or show many devices at once. On the + **On plan** and **Hidden** tabs, tick rows or **Select all (N)** — N covers + every matching row, including those behind **Show more** — and press + **Hide selected** / **Show selected**. The whole batch is one config save + with a toast such as “Hidden: 7”; a failed save changes nothing and keeps the + selection. Rows disabled or missing in Home Assistant cannot be selected + ([#618](https://github.com/Matysh/houseplan-card/issues/618)). ## v1.78.0-beta.1 — 2026-09-23 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index bc79d323..fc8a7bd5 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -14,6 +14,13 @@ сразу при выборе с сообщением, называющим предел; для растрового изображения карточка предлагает загрузить уменьшенную копию ([#617](https://github.com/Matysh/houseplan-card/issues/617)). +- Каталог **Устройства** теперь скрывает и показывает много устройств разом. На + вкладках **На плане** и **Скрытые** отметьте строки или **Выбрать все (N)** — + N учитывает все подходящие строки, в том числе за кнопкой **Показать ещё**, — + и нажмите **Скрыть выбранные** / **Показать выбранные**. Весь пакет — одно + сохранение конфига и тост вида «Скрыто: 7»; неудачное сохранение ничего не + меняет и оставляет выбор. Строки, отключённые или пропавшие в Home Assistant, + выбрать нельзя ([#618](https://github.com/Matysh/houseplan-card/issues/618)). ## v1.78.0-beta.1 — 2026-09-23 diff --git a/docs/FILTERING.md b/docs/FILTERING.md index 889256cf..2d87af6b 100644 --- a/docs/FILTERING.md +++ b/docs/FILTERING.md @@ -117,6 +117,11 @@ the old behaviour until an editing client materialises it. - Light fill and glow do NOT count hidden devices — an invisible device casts no visible light (owner's decision). Room climate is registry-wide and unaffected, as before. +- Batch Hide/Show (#618) on the catalog's `on_plan` / `hidden` tabs writes the + same markers as the single-row action (`applyInboxVisibility` is shared: a + single row is a batch of one) in ONE `houseplan/config/set`. Show keeps the + marker with `hidden: false`, including an automatic `h…` stub, so the seeder + does not re-hide it. Only active HA bindings are selectable. - Hide/Show is also available from the lifecycle catalog; the bottom-left "Hide" / "Show" action remains in the dialog of every existing device kind, virtual included; changing it is applied by "Save". diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 5bc4fd07..4c7de12f 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -761,6 +761,17 @@ and its other entities remain deleted. The complete device stays available in - **Devices** opens one searchable lifecycle catalog. Its **On plan**, **Available**, **Hidden** and **Available again** tabs explain where every exact HA binding is and offer the next valid action; +- **Hide selected** / **Show selected** (#618): the **On plan** and **Hidden** + tabs have a checkbox on every row and **Select all (N)** above the list. N + counts every eligible row of the tab after search and **New only**, + including rows behind **Show more**. With a selection the panel shows + “Selected: K”, the action button with the count and **Clear selection**. The + whole batch is saved in one write and confirmed by a “Hidden: K” / “Shown: K” + toast. There is no confirmation prompt — the same tab reverses it. Rows that + are disabled or missing in Home Assistant, or unverified, cannot be + selected. The selection resets when you switch tabs, edit the search or + toggle **New only**, survives opening a device from the catalog, and is + cleared after a successful save; a failed save changes nothing and keeps it; - **Discovery filters** (#44) live on the **Available** tab: a switch that groups room lights into one marker (on by default) and the list of excluded integrations with search and a "Restore recommended" reset. Changes show diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 4d36edc3..c414abfd 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1040,6 +1040,7 @@ binding tombstone не вырезает её из живого устройст | Клик по маркеру | Открывает диалог настройки | | **Добавить** | Сразу открывает диалог нового устройства, без перехода в каталог | | **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие | +| **Скрыть выбранные** / **Показать выбранные** (#618) | На вкладках «На плане» и «Скрытые» у строк есть флажки, над списком — **Выбрать все (N)**. N — все подходящие строки вкладки с учётом поиска и «Только новые», включая строки за **Показать ещё**. При выборе появляются «Выбрано: K», кнопка действия с числом и **Снять выбор**. Весь пакет сохраняется одной записью, после неё — тост «Скрыто: K» или «Показано: K». Подтверждения нет: действие обратимо той же вкладкой | | **Фильтры обнаружения** (#44) | Живут на вкладке «Доступны»: тумблер «Объединять светильники комнаты» (включён по умолчанию) и список исключённых интеграций с поиском и кнопкой «Вернуть рекомендуемые». Изменения показывают счётчики «появится/скроется» до записи; Сохранить пишет настройки один раз. Фильтры влияют только на автоматических кандидатов — явно размещённое устройство из-за них не исчезает, а исключённый кандидат называет свою интеграцию в каталоге | | **Добавить виртуальное устройство** | Находится вверху каталога и создаёт маркер без HA-привязки | | **Показывать скрытые на плане** | Локальный переключатель каталога: до выхода из редактора показывает скрытые и деактивированные маркеры служебными призраками, не меняя сохранённый флаг «Скрыть» | @@ -1144,6 +1145,17 @@ binding tombstone не вырезает её из живого устройст - не создаёт световое пятно и не считается источником для заливки «Свет»; - может продолжать участвовать в среднем LQI и климатических показателях назначенной комнаты; скрытие значка не меняет принадлежность данных. +Много устройств разом скрывают и показывают из каталога: **Устройства → На +плане** → флажки или **Выбрать все (N)** → **Скрыть выбранные**; обратно — +**Устройства → Скрытые** → **Показать выбранные**. Результат тот же, что у +одиночных кнопок «Скрыть»/«Показать» по каждой строке, но сохранение одно. +Строки со статусом «Отключено в Home Assistant», «Привязки больше нет в HA» или +«Реестр HA временно недоступен» выбрать нельзя — флажок неактивен и объясняет +причину. Выбор сбрасывается при смене вкладки, поиска и «Только новые», +переживает открытие диалога устройства из каталога и очищается после +успешного сохранения. Если сохранить не удалось, ни одно устройство не +меняется, показывается ошибка, а выбор остаётся для повтора. + ### Деактивированные в Home Assistant Если устройство, привязанная сущность или все сущности устройства имеют diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 17d0b55b..636abfa6 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -2064,6 +2064,55 @@ const MUTANT_DEFINITIONS = [ replace: ' if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;', }], }, + // #618: batch Hide/Show in the Devices catalog. + { + id: 'device-inbox-batch-eligibility-active', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="issue 618" test/device-inbox.test.mjs', + because: 'an HA-disabled, orphaned or unverified binding must be neither selectable nor counted in ' + + '"Select all (N)"; otherwise a batch writes hidden flags the single-row action refuses (#618 B2)', + patches: [{ + file: 'src/device-inbox.ts', + find: " return isInboxBatchTab(category) && status.kind === 'active';", + replace: ' return isInboxBatchTab(category) && !!status.kind;', + }], + }, + { + id: 'device-inbox-show-keeps-stub', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="issue 618" test/device-inbox.test.mjs', + because: 'Show must keep the marker with hidden:false; dropping an automatic stub lets the seeder ' + + 'hide the device again on the next rebuild (docs/FILTERING.md, #618 B5)', + patches: [{ + file: 'src/device-inbox.ts', + find: ' && (marker.binding !== row.binding || marker.removed === true)),\n updated,\n ];', + replace: ' && (marker.binding !== row.binding || marker.removed === true)),\n' + + ' ...(hidden ? [updated] : []),\n ];', + }], + }, + { + id: 'device-inbox-batch-single-write', + guard: 'node demo/smoke_device_inbox_batch.mjs', + because: 'a batch of K rows must be exactly one houseplan/config/set with expected_rev; K writes ' + + 'mean K revisions and K inert waits, the problem #618 exists to remove', + patches: [{ + file: 'src/device-inbox-batch.ts', + find: ' await deps.saveConfigNow(); // #618 B5: one write per batch', + replace: ' for (let write = 0; write < rows.length; write += 1) await deps.saveConfigNow(); ' + + '// #618 B5: one write per batch', + }], + }, + { + id: 'device-inbox-batch-rollback', + guard: 'node demo/smoke_device_inbox_batch.mjs', + because: 'a rejected batch write must leave every marker unchanged; without the rollback the ' + + 'catalog shows K rows moved that the server never accepted (#618 B8)', + patches: [{ + file: 'src/device-inbox-batch.ts', + find: ' if (host._serverCfg === cfg) cfg.markers = previousMarkers; // #618 B8 rollback', + replace: ' // rollback removed by mutant', + }], + }, { id: 'live-child-still-suppressed-by-parent-tombstone', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/src/device-inbox-batch.ts b/src/device-inbox-batch.ts new file mode 100644 index 00000000..7c1bf61d --- /dev/null +++ b/src/device-inbox-batch.ts @@ -0,0 +1,190 @@ +/** + * #618: batch Hide/Show in the Devices catalog. + * + * Selection is ephemeral dialog state (`selected`, exact bindings). The + * write is shared with the single-row action: one pure fold + * (`applyInboxVisibility`), one `houseplan/config/set` with the usual + * `expected_rev`, rollback on any failure. Lives in the lazy editor graph. + */ +import { html, nothing, type TemplateResult } from 'lit'; +import { + applyInboxVisibility, effectiveInboxSelection, filterDeviceInbox, isInboxBatchTab, + selectableInboxRows, type DeviceInboxCategory, type DeviceInboxRow, +} from './device-inbox'; +import type { I18nKey } from './i18n'; +import type { Marker } from './types'; + +/** B6: `busy` sentinel while a batch write is in flight (drives `inert`). */ +export const INBOX_BATCH_BUSY = '__batch__'; + +export interface InboxBatchDialog { + tab: DeviceInboxCategory; + search: string; + onlyNew: boolean; + anchor?: string; + busy?: string; + selected?: string[]; +} + +export interface InboxBatchHost { + _deviceInbox: D | null; + _serverCfg: { markers: Marker[] } | null; + _regSignature: string; + _deviceInboxMemo: unknown; + _maybeRebuildDevices: () => void; + _showToast: (msg: string) => void; + _t: (key: I18nKey, vars?: Record) => string; + _errText: (e: any) => string; // any-ok: mirrors the host port signature +} + +export interface InboxBatchDeps { + rows: () => DeviceInboxRow[]; + saveConfigNow: () => Promise; +} + +export interface InboxBatchView { + batchTab: boolean; + selectable: DeviceInboxRow[]; + selectableKeys: Set; + chosen: DeviceInboxRow[]; + chosenKeys: Set; +} + +/** B3: selectable rows of the dialog's tab over the whole filtered set. */ +export function inboxSelectableFor(rows: readonly DeviceInboxRow[], dialog: InboxBatchDialog): DeviceInboxRow[] { + if (!isInboxBatchTab(dialog.tab)) return []; + return selectableInboxRows(filterDeviceInbox( + rows, dialog.tab, dialog.search, dialog.tab === 'on_plan' && dialog.onlyNew, + )); +} + +export function inboxBatchView(dialog: InboxBatchDialog, filtered: readonly DeviceInboxRow[]): InboxBatchView { + const batchTab = isInboxBatchTab(dialog.tab); + const selectable = batchTab ? selectableInboxRows(filtered) : []; + const chosen = effectiveInboxSelection(dialog.selected, selectable); + return { + batchTab, selectable, chosen, + selectableKeys: new Set(selectable.map((row) => row.key)), + chosenKeys: new Set(chosen.map((row) => row.key)), + }; +} + +export function toggleInboxSelection( + host: InboxBatchHost, key: string, checked: boolean, +): void { + const dialog = host._deviceInbox; + if (!dialog || dialog.busy) return; + const rest = (dialog.selected || []).filter((item) => item !== key); + host._deviceInbox = { ...dialog, selected: checked ? [...rest, key] : rest }; +} + +export function selectAllInbox( + host: InboxBatchHost, deps: InboxBatchDeps, checked: boolean, +): void { + const dialog = host._deviceInbox; + if (!dialog || dialog.busy) return; + host._deviceInbox = { + ...dialog, selected: checked ? inboxSelectableFor(deps.rows(), dialog).map((row) => row.key) : [], + }; +} + +/** Hide selected (On plan) / Show selected (Hidden). */ +export async function setInboxSelectionHidden( + host: InboxBatchHost, deps: InboxBatchDeps, hidden: boolean, +): Promise { + const dialog = host._deviceInbox; + if (!dialog || dialog.busy || dialog.tab !== (hidden ? 'on_plan' : 'hidden')) return; + const rows = effectiveInboxSelection(dialog.selected, inboxSelectableFor(deps.rows(), dialog)); + if (!rows.length) return; + await writeInboxVisibility(host, deps, rows, hidden, true); +} + +/** One Hide/Show transaction for a single row (`batch=false`) or a batch. */ +export async function writeInboxVisibility( + host: InboxBatchHost, deps: InboxBatchDeps, + rows: readonly DeviceInboxRow[], hidden: boolean, batch: boolean, +): Promise { + const dialog = host._deviceInbox; + const cfg = host._serverCfg; + if (!dialog || !cfg || dialog.busy || !rows.length) return; + const previousMarkers = cfg.markers || []; + const applied = applyInboxVisibility(previousMarkers, rows, hidden, () => `m_${Date.now().toString(36)}`); + if (!applied.changed) return; + cfg.markers = applied.markers; + host._deviceInbox = { + ...dialog, + busy: batch ? INBOX_BATCH_BUSY : rows[0].key, + anchor: batch ? dialog.anchor : rows[0].key, + }; + const refreshRows = () => { + host._regSignature = ''; + host._deviceInboxMemo = null; + host._maybeRebuildDevices(); + }; + try { + await deps.saveConfigNow(); // #618 B5: one write per batch + refreshRows(); + if (host._deviceInbox) { + host._deviceInbox = { ...host._deviceInbox, busy: undefined, ...(batch ? { selected: [] } : {}) }; + } + host._showToast(batch + ? host._t(hidden ? 'device_inbox.batch_hidden' : 'device_inbox.batch_shown', { count: String(applied.changed) }) + : host._t('device_inbox.saved')); + } catch (error) { + if (host._serverCfg === cfg) cfg.markers = previousMarkers; // #618 B8 rollback + refreshRows(); + const current = host._deviceInbox; + if (current) { + // B8/B9: keep the selection, narrowed to rows still actionable after + // the rollback or the conflict re-read. + const selected = current.selected + ? effectiveInboxSelection(current.selected, inboxSelectableFor(deps.rows(), current)).map((row) => row.key) + : undefined; + host._deviceInbox = { ...current, busy: undefined, selected }; + } + host._showToast(host._t('toast.error', { err: host._errText(error) })); + } +} + +/** B4/§6: "Select all (N)" + "Selected: K · action (K) · Clear selection". */ +export function renderInboxBatchPanel( + host: InboxBatchHost, deps: InboxBatchDeps, dialog: D, view: InboxBatchView, +): TemplateResult | typeof nothing { + if (!view.batchTab) return nothing; + const { selectable, chosen } = view; + const count = { count: String(chosen.length) }; + return html`
+ + ${dialog.selected?.length ? html`
+ ${host._t('device_inbox.selected_count', count)} + + +
` : nothing} +
`; +} + +/** §6: row checkbox left of the icon; an inactive one explains why. */ +export function renderInboxRowSelect( + host: InboxBatchHost, dialog: D, view: InboxBatchView, row: DeviceInboxRow, +): TemplateResult | typeof nothing { + if (!view.batchTab) return nothing; + const canSelect = view.selectableKeys.has(row.key); + const hint = canSelect ? '' + : host._t(dialog.tab === 'hidden' ? 'device_inbox.show_disabled' : 'device_inbox.select_inactive'); + return html``; +} diff --git a/src/device-inbox.ts b/src/device-inbox.ts index abe9ae5b..8647404c 100644 --- a/src/device-inbox.ts +++ b/src/device-inbox.ts @@ -7,6 +7,7 @@ */ import type { HaBindingStatus } from './ha-binding-status'; import { isRemovedPlanEntity, removedPlanBindings } from './devices'; +import { markerIdForBinding } from './logic'; import type { DevItem, Marker } from './types'; export type DeviceInboxCategory = 'on_plan' | 'available' | 'hidden' | 'readd'; @@ -239,6 +240,7 @@ export function buildDeviceInbox(input: DeviceInboxInput): DeviceInboxRow[] { : areaId && spaceId ? 'available' : 'no_bound_room'); const isRendered = !!runtime && (!runtime.hidden || showHiddenOnPlan); const active = status.kind === 'active'; + const visibilityAllowed = inboxVisibilityAllowed(category, status); const canFind = isRendered && (active || (status.kind === 'ha_disabled' && showHiddenOnPlan)); const searchText = [ @@ -256,8 +258,8 @@ export function buildDeviceInbox(input: DeviceInboxInput): DeviceInboxRow[] { kind, isNew: !!runtime && newDeviceIds.has(runtime.id), searchText, canFind, canEdit: !!runtime || !!live, - canHide: category === 'on_plan' && active, - canShow: category === 'hidden' && active, + canHide: category === 'on_plan' && visibilityAllowed, + canShow: category === 'hidden' && visibilityAllowed, canAdd: (category === 'available' || category === 'readd') && active, }); } @@ -276,3 +278,75 @@ export function filterDeviceInbox( && (!onlyNew || row.isNew) && (!needle || row.searchText.includes(needle))); } + +/** #618: the two catalog tabs whose rows can be selected for a batch. */ +export type DeviceInboxBatchTab = 'on_plan' | 'hidden'; + +export function isInboxBatchTab(category: DeviceInboxCategory): category is DeviceInboxBatchTab { + return category === 'on_plan' || category === 'hidden'; +} + +/** #618 B2: one source for "Hide"/"Show" availability. The single-row + * buttons (`canHide`/`canShow`) and batch selection both read it, so a row + * that cannot be hidden alone can never be hidden in a batch either. */ +export function inboxVisibilityAllowed(category: DeviceInboxCategory, status: HaBindingStatus): boolean { + return isInboxBatchTab(category) && status.kind === 'active'; +} + +/** #618 B2/B3: rows of an already filtered tab that may be selected. The + * caller passes the full filtered set (search and "New only" applied, but not + * the "Show more" limit), so "Select all (N)" covers rows below the fold. */ +export function selectableInboxRows(rows: readonly DeviceInboxRow[]): DeviceInboxRow[] { + return rows.filter((row) => inboxVisibilityAllowed(row.category, row.status)); +} + +/** #618 B4/B9: the part of a stored selection that is still actionable, in + * row order. Keys that stopped being selectable drop out silently. */ +export function effectiveInboxSelection( + selected: readonly string[] | undefined, selectable: readonly DeviceInboxRow[], +): DeviceInboxRow[] { + if (!selected?.length) return []; + const wanted = new Set(selected); + return selectable.filter((row) => wanted.has(row.key)); +} + +export interface InboxVisibilityResult { + markers: Marker[]; + /** Markers whose saved `hidden` value actually changed (or were created). */ + changed: number; +} + +/** #618 B5: apply Hide/Show to exact bindings. A single-row action is a + * batch of one; a batch equals the left fold of single-row actions, so the + * config is the same whichever path wrote it. + * + * - Show keeps the marker with `hidden: false` (an automatically hidden stub + * is kept too; it is the anti-reseed guard of docs/FILTERING.md); + * - Hide of an automatic device without a marker creates `{id, binding, + * hidden: true}` with the id of `markerIdForBinding`; + * - only `hidden` changes on a marker with settings; + * - an exact binding never ends up with two live markers. */ +export function applyInboxVisibility( + markers: readonly Marker[], + rows: readonly Pick[], + hidden: boolean, + newId: () => string, +): InboxVisibilityResult { + let next: Marker[] = [...markers]; + let changed = 0; + for (const row of rows) { + const live = next.find((marker) => !marker.removed && marker.binding === row.binding); + if (!hidden && !live) continue; + const id = live?.id || markerIdForBinding(row.binding, row.markerId, newId); + const updated: Marker = live + ? { ...live, hidden } + : { id, binding: row.binding, hidden: true }; + if (!live || live.hidden !== hidden) changed += 1; + next = [ + ...next.filter((marker) => marker.id !== id + && (marker.binding !== row.binding || marker.removed === true)), + updated, + ]; + } + return { markers: next, changed }; +} diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 112b303b..bf6111b5 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -514,6 +514,8 @@ interface DeviceInboxDialogState { /** Logical row restored after a nested marker dialog closes. */ anchor?: string; busy?: string; + /** #618: exact bindings selected for a batch Hide/Show. */ + selected?: string[]; } type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown }; diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 99d6ca83..52a9eb40 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -130,6 +130,9 @@ import { bindingCandidates, buildDeviceInbox, filterDeviceInbox, type DeviceInboxCategory, type DeviceInboxReason, type DeviceInboxRow, } from './device-inbox'; +import { + inboxBatchView, renderInboxBatchPanel, renderInboxRowSelect, writeInboxVisibility, type InboxBatchDeps, +} from './device-inbox-batch'; import { formatToggleIntent, projectedTapAction, type ResolvedToggleIntent, type ResolvedToggleTarget, type ToggleNextEffect, type ToggleNoneReason, type ToggleSkipReason, @@ -427,6 +430,9 @@ interface DeviceInboxDialogState { /** Logical row restored after a nested marker dialog closes. */ anchor?: string; busy?: string; + /** #618: exact bindings selected for a batch Hide/Show. Ephemeral, lives + * only in this dialog state (and therefore survives `_deviceInboxReturn`). */ + selected?: string[]; } export interface HouseplanEditorHostPort { @@ -7112,36 +7118,11 @@ public _openInboxMarker(row: DeviceInboxRow, add = false): void { } public async _setInboxHidden(row: DeviceInboxRow, hidden: boolean): Promise { - const dialog = this.host._deviceInbox; - const cfg = this.host._serverCfg; - if (!dialog || !cfg || dialog.busy || row.status.kind !== 'active') return; - const previous = cfg.markers || []; - const live = previous.find((marker) => !marker.removed && marker.binding === row.binding); - if (!hidden && !live) return; - const id = live?.id || markerIdForBinding( - row.binding, row.markerId, () => `m_${Date.now().toString(36)}`, - ); - const next: Marker = live - ? { ...live, hidden } - : { id, binding: row.binding, hidden: true }; - cfg.markers = [ - ...previous.filter((marker) => marker.id !== id - && (marker.binding !== row.binding || marker.removed === true)), - next, - ]; - this.host._deviceInbox = { ...dialog, busy: row.key, anchor: row.key }; - try { - await this._saveConfigNow(); - this.host._regSignature = ''; - this.host._deviceInboxMemo = null; - this.host._maybeRebuildDevices(); - if (this.host._deviceInbox) this.host._deviceInbox = { ...this.host._deviceInbox, busy: undefined }; - this.host._showToast(this.host._t('device_inbox.saved')); - } catch (error: any) { - if (this.host._serverCfg === cfg) cfg.markers = previous; - if (this.host._deviceInbox) this.host._deviceInbox = { ...this.host._deviceInbox, busy: undefined }; - this.host._showToast(this.host._t('toast.error', { err: this.host._errText(error) })); - } + if (row.status.kind !== 'active') return; + await writeInboxVisibility(this.host, this._inboxBatchDeps(), [row], hidden, false); + } +private _inboxBatchDeps(): InboxBatchDeps { + return { rows: () => this._deviceInboxRows(), saveConfigNow: () => this._saveConfigNow() }; } public _findInboxDevice(row: DeviceInboxRow): void { @@ -7176,7 +7157,7 @@ public _deviceInboxTabKey = (event: KeyboardEvent): void => { const tabs: DeviceInboxCategory[] = ['on_plan', 'available', 'hidden', 'readd']; const offset = event.key === 'ArrowRight' ? 1 : -1; const index = (tabs.indexOf(dialog.tab) + offset + tabs.length) % tabs.length; - this.host._deviceInbox = { ...dialog, tab: tabs[index], limit: 100, onlyNew: false }; + this.host._deviceInbox = { ...dialog, tab: tabs[index], limit: 100, onlyNew: false, selected: [] }; event.preventDefault(); }; @@ -11406,6 +11387,7 @@ public _renderDeviceInbox(): TemplateResult { rows, dialog.tab, dialog.search, dialog.tab === 'on_plan' && dialog.onlyNew, ); const visible = filtered.slice(0, dialog.limit); + const batch = inboxBatchView(dialog, filtered); // #618 B1: On plan / Hidden only const tabLabel = (tab: DeviceInboxCategory) => this.host._t(`device_inbox.tab_${tab}` as I18nKey); const emptyKey = `device_inbox.empty_${dialog.tab}` as I18nKey; const openVirtual = () => { @@ -11423,7 +11405,7 @@ public _renderDeviceInbox(): TemplateResult { (this.host._deviceInbox = { - ...dialog, search: (event.target as HTMLInputElement).value, limit: 100, + ...dialog, search: (event.target as HTMLInputElement).value, limit: 100, selected: [], })} /> `)} @@ -11442,7 +11426,7 @@ public _renderDeviceInbox(): TemplateResult { ${dialog.tab === 'on_plan' ? html`` : nothing} ${dialog.tab === 'available' ? html`