Compare commits

..

1 Commits

Author SHA1 Message Date
Aidan Timson 900efcba6c Add a pull request standards workflow 2026-06-11 12:46:43 +01:00
115 changed files with 1648 additions and 2734 deletions
+1
View File
@@ -30,6 +30,7 @@ jobs:
"Do Not Review",
"Blocked",
"has-parent",
"Needs Template",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -0,0 +1,185 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
- reopened
- ready_for_review
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check pull request follows contribution standards
runs-on: ubuntu-latest
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s*${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -97,7 +97,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+8 -8
View File
@@ -34,13 +34,13 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
@@ -131,13 +131,13 @@
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -186,7 +186,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.4",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -194,7 +194,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.60.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+2 -1
View File
@@ -1,7 +1,7 @@
// Load a resource and get a promise when loading done.
// From: https://davidwalsh.name/javascript-loader
const _load = (tag: "link" | "script", url: string, type?: "module") =>
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
// This promise will be used by Promise.all to determine success or failure
new Promise((resolve, reject) => {
const element = document.createElement(tag);
@@ -33,4 +33,5 @@ const _load = (tag: "link" | "script", url: string, type?: "module") =>
});
export const loadCSS = (url: string) => _load("link", url);
export const loadJS = (url: string) => _load("script", url);
export const loadImg = (url: string) => _load("img", url);
export const loadModule = (url: string) => _load("script", url, "module");
+41
View File
@@ -0,0 +1,41 @@
/**
* Scroll to a specific y coordinate.
*
* Copied from paper-scroll-header-panel.
*
* @method scroll
* @param {number} top The coordinate to scroll to, along the y-axis.
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
*/
export default function scrollToTarget(element, target) {
// the scroll event will trigger _updateScrollState directly,
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
// Calling _updateScrollState will ensure that the states are synced correctly.
const top = 0;
const scroller = target;
const easingFn = function easeOutQuad(t, b, c, d) {
t /= d;
return -c * t * (t - 2) + b;
};
const animationId = Math.random();
const duration = 200;
const startTime = Date.now();
const currentScrollTop = scroller.scrollTop;
const deltaScrollTop = top - currentScrollTop;
element._currentAnimationId = animationId;
(function updateFrame() {
const now = Date.now();
const elapsedTime = now - startTime;
if (elapsedTime > duration) {
scroller.scrollTop = top;
} else if (element._currentAnimationId === animationId) {
scroller.scrollTop = easingFn(
elapsedTime,
currentScrollTop,
deltaScrollTop,
duration
);
requestAnimationFrame(updateFrame.bind(element));
}
}).call(element);
}
+13
View File
@@ -3,6 +3,8 @@ import type { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
@@ -43,6 +45,17 @@ export const setupLeafletMap = async (
return [map, Leaflet, tileLayer];
};
export const replaceTileLayer = (
leaflet: LeafletModuleType,
map: Map,
tileLayer: TileLayer
): TileLayer => {
map.removeLayer(tileLayer);
tileLayer = createTileLayer(leaflet);
tileLayer.addTo(map);
return tileLayer;
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
+3
View File
@@ -0,0 +1,3 @@
/** An empty image which can be set as src of an img element. */
export const emptyImageBase64 =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+30 -37
View File
@@ -19,40 +19,6 @@ import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
@@ -172,10 +138,21 @@ const computeStateToPartsFromEntityAttributes = (
}
if (parts.length) {
const TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const valueParts: ValuePart[] = [];
for (const part of parts) {
const type = MONETARY_TYPE_MAP[part.type];
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
@@ -214,7 +191,7 @@ const computeStateToPartsFromEntityAttributes = (
return [{ type: "value", value: value }];
}
if (DATE_TIME_DOMAINS.has(domain)) {
if (["date", "input_datetime", "time"].includes(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
@@ -273,7 +250,23 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_DOMAINS.has(domain) ||
[
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
+5 -6
View File
@@ -4,10 +4,9 @@ import { updateIsInstalling } from "../../data/update";
export const updateIcon = (stateObj: HassEntity, state?: string) => {
const compareState = state ?? stateObj.state;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware. Show the installing icon regardless of state.
if (updateIsInstalling(stateObj as UpdateEntity)) {
return "mdi:package-down";
}
return compareState === "on" ? "mdi:package-up" : "mdi:package";
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? "mdi:package-down"
: "mdi:package-up"
: "mdi:package";
};
+2 -21
View File
@@ -40,25 +40,6 @@ export const numberFormatToLocale = (
}
};
// Constructing an Intl.NumberFormat is comparatively expensive, and these
// formatters are created on every numeric state render. The number of distinct
// (locale, options) combinations is small and bounded in practice, so cache the
// instances instead of rebuilding them on every call.
const numberFormatCache = new Map<string, Intl.NumberFormat>();
const getNumberFormatter = (
locale: string | string[] | undefined,
options: Intl.NumberFormatOptions
): Intl.NumberFormat => {
const key = JSON.stringify([locale, options]);
let formatter = numberFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
numberFormatCache.set(key, formatter);
}
return formatter;
};
/**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
@@ -94,7 +75,7 @@ export const formatNumberToParts = (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
) {
return getNumberFormatter(
return new Intl.NumberFormat(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
@@ -106,7 +87,7 @@ export const formatNumberToParts = (
localeOptions?.number_format === NumberFormat.none
) {
// If NumberFormat is none, use en-US format without grouping.
return getNumberFormatter(
return new Intl.NumberFormat(
"en-US",
getDefaultFormatOptions(num, {
...options,
+2 -8
View File
@@ -11,12 +11,6 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
// A document node cannot have a textarea appended directly (only the single
// documentElement is allowed), so fall back to its body. Shadow roots and
// elements can hold the textarea directly, which keeps execCommand working
// inside dialogs that trap focus.
const container: Node =
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
const el = document.createElement("textarea");
el.value = str;
@@ -25,8 +19,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
el.style.top = "0";
el.style.left = "0";
el.style.opacity = "0";
container.appendChild(el);
root.appendChild(el);
el.select();
document.execCommand("copy");
container.removeChild(el);
root.removeChild(el);
};
-30
View File
@@ -1,30 +0,0 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ReactiveControllerHost } from "lit";
import { clamp } from "../number/clamp";
// Count columns from the container's real width (not the viewport) so a
// docked sidebar is accounted for, like the dashboard sections view.
const MIN_COLUMN_WIDTH = 320;
const DEFAULT_COLUMN_GAP = 16;
const parsePx = (value: string) => parseInt(value, 10) || 0;
export const createColumnsController = (
host: ReactiveControllerHost & Element,
maxColumns: number
) =>
new ResizeController<number>(host, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
if (!entry) {
return maxColumns;
}
const width = entry.contentRect.width;
const gap =
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
return clamp(columns, 1, maxColumns);
},
});
+46 -86
View File
@@ -1,23 +1,5 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -37,47 +19,11 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) {
const pointData = getPointData(point);
@@ -89,39 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
frames.set(frameIndex, [{ point, x, y }]);
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
frame.push({ point, x, y });
}
}
// Convert frames back to points
const result: T[] = [];
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
}
}
+5 -36
View File
@@ -394,18 +394,6 @@ export class HaChartBase extends LitElement {
return nothing;
}
const datasets = ensureArray(this.data!);
// Index datasets by id and name so each legend item is an O(1) lookup
// instead of scanning every dataset twice. Charts can have many series.
const datasetById = new Map<unknown, (typeof datasets)[number]>();
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
for (const dataset of datasets) {
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
datasetById.set(dataset.id, dataset);
}
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
datasetByName.set(dataset.name, dataset);
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -425,10 +413,10 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let id = "";
let value = "";
let noLabelClick = false;
const name = typeof item === "string" ? item : (item.name ?? "");
let id: string;
if (typeof item === "string") {
id = item;
} else {
@@ -438,7 +426,9 @@ export class HaChartBase extends LitElement {
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset = datasetById.get(id) ?? datasetByName.get(id);
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
@@ -1530,9 +1520,7 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
line-height: 1;
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1570,25 +1558,6 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
@@ -147,14 +147,6 @@ export class StateHistoryChartLine extends LitElement {
this.hass.config
);
const datapoints: Record<string, any>[] = [];
// Index the hovered points by series so the per-dataset lookup below is
// O(1) instead of scanning `params` for every dataset on each mouse move.
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
for (const p of params) {
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
paramsBySeriesIndex.set(p.seriesIndex, p);
}
}
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
@@ -162,7 +154,9 @@ export class StateHistoryChartLine extends LitElement {
) {
return;
}
const param = paramsBySeriesIndex.get(index);
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
@@ -446,10 +440,6 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const endTime = this.endTime;
// Work with numeric epoch timestamps (ms) instead of Date objects below.
// Charts can hold a huge number of points, and allocating a Date per point
// is needless GC pressure; the "time" axis consumes numbers natively.
const endTimeMs = endTime.getTime();
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
@@ -461,9 +451,9 @@ export class StateHistoryChartLine extends LitElement {
const data: LineSeriesOption[] = [];
const pushData = (timestamp: number, datavalues: any[] | null) => {
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTimeMs) {
if (timestamp > endTime) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
@@ -634,11 +624,11 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
@@ -756,27 +746,31 @@ export class StateHistoryChartLine extends LitElement {
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(entityState.last_changed, series);
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: number;
let lastNullDate: number | null = null;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = entityState.last_changed;
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDate - lastDate) / (date - lastDate)) +
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(lastNullDate + 1, [null]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
@@ -815,17 +809,17 @@ export class StateHistoryChartLine extends LitElement {
}
// Add an entry for final values
pushData(endTimeMs, prevValues);
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const nowMs = Date.now();
const now = new Date();
// allow 1s of leeway for "now"
const isUpToNow = nowMs - endTimeMs <= 1000;
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([nowMs, currentValue]);
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
+2 -8
View File
@@ -215,16 +215,10 @@ export class HaDataTable extends LitElement {
if (clear) {
this._checkedRows = [];
}
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
const rowLookup = new Map(
(this._filteredData || []).map((data) => [data[this.id], data])
);
const checkedRows = new Set(this._checkedRows);
ids.forEach((id) => {
const row = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
const row = this._filteredData?.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
-1
View File
@@ -183,7 +183,6 @@ export class HaControlSelectMenu extends LitElement {
gap: 10px;
width: 100%;
user-select: none;
font-family: var(--ha-font-family-body, inherit);
font-style: normal;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
+5 -21
View File
@@ -12,20 +12,6 @@ import type {
HaFormSelectSchema,
} from "./types";
/**
* The underlying select returns option values as strings. Map a selected value
* back to its original option value so the source type is retained (for example
* a number coming from a backend `vol.In` schema), falling back to the value
* itself when no option matches.
*/
export const matchSelectOptionValue = (
options: HaFormSelectSchema["options"],
value: string
): HaFormSelectData => {
const option = options.find((opt) => String(opt[0]) === String(value));
return option ? option[0] : value;
};
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -80,18 +66,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: HaFormSelectData | undefined = ev.detail.value;
if (value === "") {
value = undefined;
} else if (value != null) {
value = matchSelectOptionValue(this.schema.options, value);
}
let value: string | undefined = ev.detail.value;
if (value === this.data) {
return;
}
if (value === "") {
value = undefined;
}
fireEvent(this, "value-changed", {
value,
});
-2
View File
@@ -41,8 +41,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
};
@customElement("ha-icon")
@@ -354,9 +354,7 @@ export class HaSerialPortSelector extends LitElement {
}
private get _selectorDomain(): string | undefined {
// `domain` is the integration domain even in options flows, where the flow
// handler is the config entry id instead.
return this.context?.domain;
return this.context?.handler;
}
private _memoRecommendedDomains = memoizeOne(
+22 -22
View File
@@ -3,7 +3,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
@@ -12,9 +11,6 @@ const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
--total-top-app-bar-height: calc(
var(--header-height, 0px) + var(--sub-row-height, 0px)
);
@@ -25,11 +21,10 @@ export const haTopAppBarFixedStyles = css`
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: absolute;
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: 100%;
width: var(--ha-top-app-bar-width, 100%);
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -121,17 +116,17 @@ export const haTopAppBarFixedStyles = css`
}
.top-app-bar-fixed-adjust {
box-sizing: border-box;
position: absolute;
top: calc(
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
padding-top: calc(
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
);
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
overflow: auto;
}
:host([narrow]) .top-app-bar-fixed-adjust {
@@ -151,8 +146,6 @@ export class HaTopAppBarFixed extends LitElement {
@query(".sub-row") protected _subRowElement?: HTMLElement;
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
@state() private _hasSubRow = false;
private _scrollTarget?: HTMLElement | Window;
@@ -161,13 +154,14 @@ export class HaTopAppBarFixed extends LitElement {
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || this._scrollElement || window;
return this._scrollTarget || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
@@ -189,6 +183,7 @@ export class HaTopAppBarFixed extends LitElement {
if (this.hasUpdated) {
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -248,7 +243,7 @@ export class HaTopAppBarFixed extends LitElement {
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
</div>`;
}
@@ -257,6 +252,7 @@ export class HaTopAppBarFixed extends LitElement {
super.firstUpdated(changedProperties);
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -274,6 +270,13 @@ export class HaTopAppBarFixed extends LitElement {
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
@@ -333,10 +336,7 @@ export class HaTopAppBarFixed extends LitElement {
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
};
static override styles: CSSResultGroup = [
haStyleScrollbar,
haTopAppBarFixedStyles,
];
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
}
declare global {
+6 -16
View File
@@ -85,25 +85,15 @@ export class HaTTSVoicePicker extends LitElement {
await listTTSVoices(this.hass, this.engineId, this.language)
).voices;
const valueIsValid =
this.value &&
this._voices?.some((voice) => voice.voice_id === this.value);
if (valueIsValid) {
if (!this.value) {
return;
}
// The current value is missing or no longer valid for the loaded voices.
// When a voice is required, auto-select the first one (the <ha-select>
// already displays it) so the value is propagated to the parent;
// otherwise clear it.
const newValue =
this.required && this._voices?.length
? this._voices[0].voice_id
: undefined;
if (newValue !== this.value) {
this.value = newValue;
if (
!this._voices ||
!this._voices.find((voice) => voice.voice_id === this.value)
) {
this.value = undefined;
fireEvent(this, "value-changed", { value: this.value });
}
}
@@ -29,7 +29,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"ha-scrollbar": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
@@ -131,7 +130,12 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
.top-app-bar-fixed-adjust--pane {
display: flex;
overflow: hidden;
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
@@ -163,7 +167,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
position: relative;
flex: 1;
height: 100%;
min-width: 0;
}
.top-app-bar-fixed-adjust--pane .content {
+13
View File
@@ -0,0 +1,13 @@
import timezones from "google-timezones-json";
export const createTimezoneListEl = () => {
const list = document.createElement("datalist");
list.id = "timezones";
Object.keys(timezones).forEach((key) => {
const option = document.createElement("option");
option.value = key;
option.innerText = timezones[key];
list.appendChild(option);
});
return list;
};
+10 -24
View File
@@ -73,44 +73,30 @@ export const getEntities = (
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
if (includeEntities) {
const includeEntitiesSet = new Set(includeEntities);
entityIds = entityIds.filter((entityId) =>
includeEntitiesSet.has(entityId)
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
const excludeEntitiesSet = new Set(excludeEntities);
entityIds = entityIds.filter(
(entityId) => !excludeEntitiesSet.has(entityId)
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
const includeDomainsSet = new Set(includeDomains);
entityIds = entityIds.filter((eid) =>
includeDomainsSet.has(computeDomain(eid))
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
const excludeDomainsSet = new Set(excludeDomains);
entityIds = entityIds.filter(
(eid) => !excludeDomainsSet.has(computeDomain(eid))
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
@@ -124,12 +110,12 @@ export const getEntities = (
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+6 -8
View File
@@ -725,18 +725,16 @@ export const mergeHistoryResults = (
}
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
const historyDataByEntity = new Map(
historyItem.data.map((d) => [d.entity_id, d])
);
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
const entities = new Set([
...historyDataByEntity.keys(),
...ltsDataByEntity.keys(),
...historyItem.data.map((d) => d.entity_id),
...ltsItem.data.map((d) => d.entity_id),
]);
for (const entity of entities) {
const historyDataItem = historyDataByEntity.get(entity);
const ltsDataItem = ltsDataByEntity.get(entity);
const historyDataItem = historyItem.data.find(
(d) => d.entity_id === entity
);
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
if (!historyDataItem || !ltsDataItem) {
newLineItem.data.push(historyDataItem || ltsDataItem!);
+7
View File
@@ -43,6 +43,11 @@ export const lightSupportsColorMode = (
mode: LightColorMode
) => entity.attributes.supported_color_modes?.includes(mode) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
(entity.attributes.color_mode &&
modesSupportingColor.includes(entity.attributes.color_mode)) ||
false;
export const lightSupportsColor = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
@@ -154,3 +159,5 @@ export const computeDefaultFavoriteColors = (
return colors;
};
export const formatTempColor = (value: number) => `${value} K`;
+1 -3
View File
@@ -128,13 +128,11 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string,
networkOnly: boolean
code: string
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
+17 -18
View File
@@ -231,24 +231,6 @@ export const computeUpdateStateDisplay = (
const state = stateObj.state;
const attributes = stateObj.attributes;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware (installed_version is newer than latest_version).
// Show the installing status regardless of state in that case.
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
if (state === "off") {
const isSkipped =
attributes.latest_version &&
@@ -259,6 +241,23 @@ export const computeUpdateStateDisplay = (
return hass.formatEntityState(stateObj);
}
if (state === "on") {
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
}
return hass.formatEntityState(stateObj);
};
@@ -10,21 +10,13 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
class DialogConfigEntrySystemOptions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -46,13 +38,6 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -83,7 +68,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
) || this._params.entry.domain,
}
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -150,7 +135,7 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -164,19 +149,11 @@ class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptio
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
@@ -403,7 +403,6 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.domain=${this._params.domain ?? this._step.handler}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
+1 -5
View File
@@ -35,10 +35,6 @@ class StepFlowForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// The integration domain this flow belongs to. Unlike `step.handler`, this is
// the domain even for options flows (where the handler is the config entry id).
@property({ attribute: false }) public domain?: string;
@state() private _loading = false;
@state() private _stepData?: Record<string, any>;
@@ -112,7 +108,7 @@ class StepFlowForm extends LitElement {
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler, domain: this.domain }}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
</div>
@@ -1103,7 +1103,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
.title .breadcrumb {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-family-heading, inherit);
line-height: 16px;
--mdc-icon-size: 16px;
padding: var(--ha-space-1);
+5 -17
View File
@@ -22,7 +22,6 @@ interface EntityInfo {
entityId: string;
entityName: string | undefined;
areaId: string | undefined;
deviceId: string | undefined;
}
@customElement("more-info-content")
@@ -121,7 +120,7 @@ class MoreInfoContent extends LitElement {
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
const { area } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
@@ -129,8 +128,7 @@ class MoreInfoContent extends LitElement {
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
return { entityId, entityName, areaId };
})
.filter(Boolean) as EntityInfo[];
@@ -142,20 +140,10 @@ class MoreInfoContent extends LitElement {
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Build name and state content config based on conditions
const name: EntityNameItem[] = [{ type: "device" }];
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
if (!allSameEntityName) {
name.push({ type: "entity" });
}
-67
View File
@@ -19,64 +19,6 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -117,15 +59,6 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
@@ -23,28 +23,18 @@ import {
} from "../../../data/application_credential";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface CredentialFormState {
domain: string;
name: string;
clientId: string;
clientSecret: string;
}
interface Domain {
id: string;
name: string;
}
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends DirtyStateProviderMixin<CredentialFormState>()(
LitElement
) {
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -86,7 +76,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
this._error = undefined;
this._loading = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, this._currentState());
this._fetchConfig();
}
@@ -111,7 +100,10 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
<ha-dialog
.open=${this._open}
@closed=${this._abortDialog}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)}
@@ -292,7 +284,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
ev.stopPropagation();
this._domain = ev.detail.value;
this._updateDescription();
this._updateDirtyState(this._currentState());
}
private async _updateDescription() {
@@ -316,16 +307,6 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
this._updateDirtyState(this._currentState());
}
private _currentState(): CredentialFormState {
return {
domain: this._domain || "",
name: this._name || "",
clientId: this._clientId || "",
clientSecret: this._clientSecret || "",
};
}
private _abortDialog() {
@@ -32,7 +32,6 @@ import {
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { ObjectSelector, Selector } from "../../../../../data/selector";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
@@ -57,15 +56,15 @@ const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
const MASKED_FIELDS = ["password", "secret", "token"];
@customElement("supervisor-app-config")
class SupervisorAppConfig extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
class SupervisorAppConfig extends LitElement {
@property({ attribute: false }) public addon!: HassioAddonDetails;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@state() private _configHasChanged = false;
@state() private _valid = true;
@state() private _canShowSchema = false;
@@ -352,7 +351,9 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
<div class="card-actions right">
<ha-progress-button
@click=${this._saveTapped}
.disabled=${this.disabled || !this.isDirtyState || !this._valid}
.disabled=${this.disabled ||
!this._configHasChanged ||
!this._valid}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -376,7 +377,6 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
protected updated(changedProperties: PropertyValues): void {
if (changedProperties.has("addon")) {
this._options = { ...this.addon.options };
this._initDirtyTracking({ type: "deep" }, this.addon.options);
}
super.updated(changedProperties);
if (
@@ -415,13 +415,11 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
private _configChanged(ev): void {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true;
this._configHasChanged = true;
this._options = ev.detail.value;
this._updateDirtyState(ev.detail.value);
} else {
this._configHasChanged = true;
this._valid = ev.detail.isValid;
if (ev.detail.isValid) {
this._updateDirtyState(ev.detail.value);
}
}
}
@@ -452,7 +450,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -471,7 +469,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (this.disabled || !this.isDirtyState || !this._valid) {
if (this.disabled || !this._configHasChanged || !this._valid) {
return;
}
@@ -501,7 +499,7 @@ class SupervisorAppConfig extends DirtyStateProviderMixin<
options,
});
this._markDirtyStateClean();
this._configHasChanged = false;
if (this.addon?.state === "started") {
await suggestSupervisorAppRestart(this, this.hass, this.addon);
}
@@ -15,16 +15,13 @@ import type {
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
class SupervisorAppNetwork extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -33,19 +30,19 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
@state() private _showOptional = false;
@state() private _configHasChanged = false;
@state() private _error?: string;
@state() private _config?: Record<string, number | null>;
@state() private _config?: Record<string, any>;
protected render() {
if (!this._config) {
return nothing;
}
const config = this._config;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
const hasHiddenOptions = Object.keys(this._config).find(
(entry) => this._config![entry] === null
);
return html`
@@ -101,7 +98,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
</ha-progress-button>
<ha-progress-button
@click=${this._saveTapped}
.disabled=${!this.isDirtyState || this.disabled}
.disabled=${!this._configHasChanged || this.disabled}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
@@ -118,10 +115,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private _createSchema = memoizeOne(
(
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(config: Record<string, number>, showOptional: boolean): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
@@ -147,14 +141,12 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
item.name;
private _setNetworkConfig(): void {
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
this._config = this.addon.network || {};
}
private _configChanged(ev: CustomEvent): void {
private async _configChanged(ev: CustomEvent): Promise<void> {
this._configHasChanged = true;
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -169,7 +161,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -196,14 +188,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
private async _saveTapped(ev: CustomEvent): Promise<void> {
if (!this.isDirtyState || this.disabled) {
if (!this._configHasChanged || this.disabled) {
return;
}
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration: Record<string, number | null> = {};
const networkconfiguration = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
@@ -214,7 +206,7 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
this._markDirtyStateClean();
this._configHasChanged = false;
const eventdata = {
success: true,
response: undefined,
@@ -261,22 +261,15 @@ export class HaConfigAppsInstalled extends LitElement {
}
.search {
display: flex;
align-items: center;
width: 100%;
height: 56px;
position: sticky;
top: 0;
z-index: 2;
background-color: var(--primary-background-color);
padding: 0 var(--ha-space-4);
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
ha-input-search {
flex: 1;
min-width: 0;
padding: var(--ha-space-3) var(--ha-space-2);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content {
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
@@ -55,20 +54,9 @@ const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
interface AreaFormState {
name: string;
aliases: string[];
labels: string[];
picture: string | null;
icon: string | null;
floor: string | null;
temperatureEntity: string | null;
humidityEntity: string | null;
}
@customElement("dialog-area-registry-detail")
class DialogAreaDetail
extends DirtyStateProviderMixin<AreaFormState>()(LitElement)
extends LitElement
implements HassDialog<AreaRegistryDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -128,23 +116,9 @@ class DialogAreaDetail
this._humidityEntity = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): AreaFormState {
return {
name: this._name,
aliases: this._aliases,
labels: this._labels,
picture: this._picture,
icon: this._icon,
floor: this._floor,
temperatureEntity: this._temperatureEntity,
humidityEntity: this._humidityEntity,
};
}
public closeDialog(): boolean {
this._open = false;
return true;
@@ -352,8 +326,6 @@ class DialogAreaDetail
if (processed.floor) {
this._floor = processed.floor;
}
this._updateDirtyState(this._currentState());
}
protected render() {
@@ -371,7 +343,7 @@ class DialogAreaDetail
header-title=${entry
? this.hass.localize("ui.panel.config.areas.editor.update_area")
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
@@ -412,9 +384,7 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid ||
this._submitting ||
(!!this._params?.entry && !this.isDirtyState)}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
@@ -448,43 +418,36 @@ class DialogAreaDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._updateDirtyState(this._currentState());
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
this._updateDirtyState(this._currentState());
}
private async _updateEntry() {
@@ -506,7 +469,6 @@ class DialogAreaDetail
} else {
await this._params!.updateEntry!(values);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error =
+290 -300
View File
@@ -34,7 +34,6 @@ import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
import { afterNextRender } from "../../../common/util/render-status";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -140,8 +139,6 @@ const NAVIGATION_ACTIONS: {
},
] as const;
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -162,8 +159,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _memberships = memoizeOne(
(
areaId: string,
@@ -293,28 +288,29 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
Object.values(this.hass.devices),
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Compute the display names on shallow copies so we can sort and render by
// them without mutating the shared registry objects.
const devices = memberships.devices.map((entry) => ({
...entry,
name: computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
),
}));
sortDeviceRegistryByName(devices, this.hass.locale.language);
const entities = memberships.entities.map((entry) => ({
...entry,
name: computeEntityRegistryName(this.hass, entry),
}));
sortEntityRegistryByName(entities, this.hass.locale.language);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
devices.forEach((entry) => {
entry.name = computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
);
});
sortDeviceRegistryByName(devices, this.hass.locale.language);
}
if (entities) {
entities.forEach((entry) => {
entry.name = computeEntityRegistryName(this.hass, entry);
});
sortEntityRegistryByName(entities, this.hass.locale.language);
}
// Group entities by domain
const groupedEntities = groupBy(entities, (entity) =>
@@ -362,267 +358,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
)
);
const infoColumn = html`
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${devices.map(
(device) => html`
<a href="/config/devices/device/${device.id}">
<ha-list-item hasMeta>
<span>${device.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`
)}
</ha-list>`
: html`
<div class="no-entries">
${this.hass.localize("ui.panel.config.devices.no_devices")}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
`;
const relatedColumn = html`
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
`;
const logbookColumn = html`
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined .header=${this.hass.localize("panel.logbook")}>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
`;
// In 2 columns the logbook goes on the right, under the shorter
// automations/scenes/scripts column, to balance the column heights.
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn], [relatedColumn], [logbookColumn]]
: columns === 2
? [[infoColumn], [relatedColumn, logbookColumn]]
: [[infoColumn, relatedColumn, logbookColumn]];
return html`
<hass-subpage
.hass=${this.hass}
@@ -667,10 +402,266 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="container">
<div class="column">
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${devices.map(
(device) => html`
<a href="/config/devices/device/${device.id}">
<ha-list-item hasMeta>
<span>${device.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`
)}
</ha-list>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.no_devices"
)}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>
`;
@@ -914,31 +905,30 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
width: 100%;
}
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
max-width: 1000px;
margin-top: 32px;
margin-bottom: 32px;
}
.column {
padding: 8px;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
padding: var(--ha-space-2);
padding: 8px;
width: 100%;
}
.column > *:not(:first-child) {
margin-top: var(--ha-space-4);
margin-top: 16px;
}
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container {
@@ -18,22 +18,13 @@ import {
} from "../../../../data/automation";
import { MODES, isMaxMode } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import type { AutomationModeDialog } from "./show-dialog-automation-mode";
interface AutomationModeState {
mode: (typeof MODES)[number];
max?: number;
}
@customElement("ha-dialog-automation-mode")
class DialogAutomationMode
extends DirtyStateProviderMixin<AutomationModeState>()(LitElement)
implements HassDialog
{
class DialogAutomationMode extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -51,10 +42,6 @@ class DialogAutomationMode
this._newMax = isMaxMode(this._newMode)
? params.config.max || AUTOMATION_DEFAULT_MAX
: undefined;
this._initDirtyTracking(
{ type: "shallow" },
{ mode: this._newMode, max: this._newMax }
);
}
public closeDialog(): boolean {
@@ -83,7 +70,6 @@ class DialogAutomationMode
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -137,11 +123,7 @@ class DialogAutomationMode
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize(
"ui.panel.config.automation.editor.change_mode"
)}
@@ -159,7 +141,6 @@ class DialogAutomationMode
} else if (!this._newMax) {
this._newMax = AUTOMATION_DEFAULT_MAX;
}
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
private _valueChanged(ev: InputEvent) {
@@ -167,7 +148,6 @@ class DialogAutomationMode
const target = ev.target as HaInput;
if (target.name === "max") {
this._newMax = Number(target.value);
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
}
@@ -25,7 +25,6 @@ import type { GenDataTaskResult } from "../../../../data/ai_task";
import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
@@ -39,18 +38,8 @@ import type {
SaveDialogParams,
} from "./show-dialog-automation-save";
interface AutomationSaveState {
name?: string;
description?: string;
icon?: string;
entryUpdates: EntityRegistryUpdate;
}
@customElement("ha-dialog-automation-save")
class DialogAutomationSave
extends DirtyStateProviderMixin<AutomationSaveState>()(LitElement)
implements HassDialog
{
class DialogAutomationSave extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -92,16 +81,6 @@ class DialogAutomationSave
this._entryUpdates.labels.length > 0 ? "labels" : "",
this._entryUpdates.area ? "area" : "",
].filter(Boolean);
this._initDirtyTracking(
{ type: "deep" },
{
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
}
);
}
public closeDialog(): boolean {
@@ -273,7 +252,6 @@ class DialogAutomationSave
.open=${this._open}
@closed=${this._dialogClosed}
header-title=${this._params.title || title}
.preventScrimClose=${this.isDirtyState}
>
${this._params.hideInputs
? nothing
@@ -303,11 +281,7 @@ class DialogAutomationSave
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
@@ -325,28 +299,17 @@ class DialogAutomationSave
this._visibleOptionals = [...this._visibleOptionals, option];
}
private _trackDirtyState() {
this._updateDirtyState({
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
});
}
private _registryEntryChanged(ev) {
ev.stopPropagation();
const id: string = ev.target.id;
const value = ev.detail.value;
this._entryUpdates = { ...this._entryUpdates, [id]: value };
this._trackDirtyState();
}
private _iconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._newIcon = ev.detail.value || undefined;
this._trackDirtyState();
}
private _valueChanged(ev: CustomEvent) {
@@ -357,7 +320,6 @@ class DialogAutomationSave
} else {
this._newName = target.value;
}
this._trackDirtyState();
}
private _handleDiscard() {
@@ -425,7 +387,6 @@ class DialogAutomationSave
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
this._trackDirtyState();
}
private async _save(): Promise<void> {
@@ -164,9 +164,6 @@ export class HaPlatformCondition extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
@@ -251,13 +251,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
if (filteredAutomations === null) {
return [];
}
// Build lookups once instead of scanning the registries for every row.
const entityRegLookup = new Map(
entityReg.map((reg) => [reg.entity_id, reg])
);
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
return (
filteredAutomations
? automations.filter((automation) =>
@@ -265,11 +258,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
)
: automations
).map((automation) => {
const entityRegEntry = entityRegLookup.get(automation.entity_id);
const entityRegEntry = entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelLookup!.get(lbl)!)
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
.filter(Boolean);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
@@ -55,9 +55,6 @@ export class HaConversationTrigger
@click=${this._removeOption}
slot="end"
.path=${mdiClose}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.delete"
)}
></ha-icon-button>
</ha-input>
`
@@ -81,9 +78,6 @@ export class HaConversationTrigger
@click=${this._addOption}
slot="end"
.path=${mdiPlus}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.add_sentence"
)}
></ha-icon-button>
</ha-input>`;
}
@@ -201,9 +201,6 @@ export class HaPlatformTrigger extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
@@ -15,7 +15,6 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type {
BackupConfig,
BackupMutableConfig,
@@ -83,10 +82,7 @@ const RECOMMENDED_CONFIG: BackupConfig = {
};
@customElement("ha-dialog-backup-onboarding")
class DialogBackupOnboarding
extends DirtyStateProviderMixin<BackupConfig>()(LitElement)
implements HassDialog
{
class DialogBackupOnboarding extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -119,7 +115,6 @@ class DialogBackupOnboarding
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._config!);
}
public closeDialog() {
@@ -174,7 +169,6 @@ class DialogBackupOnboarding
try {
await this._save(true);
this._params?.submit!(true);
this._markDirtyStateClean();
this.closeDialog();
} catch (err) {
// eslint-disable-next-line no-console
@@ -220,7 +214,7 @@ class DialogBackupOnboarding
<ha-dialog
.open=${this._open}
header-title=${this._stepTitle}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${isFirstStep
@@ -299,7 +293,6 @@ class DialogBackupOnboarding
password: this._config.create_backup.password,
},
};
this._updateDirtyState(this._config);
this._done();
}
@@ -522,7 +515,6 @@ class DialogBackupOnboarding
include_addons: data.include_addons || null,
},
};
this._updateDirtyState(this._config);
}
private _scheduleChanged(ev) {
@@ -532,7 +524,6 @@ class DialogBackupOnboarding
schedule: value.schedule,
retention: value.retention,
};
this._updateDirtyState(this._config);
}
private _agentsConfigChanged(ev) {
@@ -544,7 +535,6 @@ class DialogBackupOnboarding
agent_ids: agents,
},
};
this._updateDirtyState(this._config);
}
static get styles(): CSSResultGroup {
@@ -12,17 +12,13 @@ import {
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup
extends DirtyStateProviderMixin<string>()(LitElement)
implements HassDialog
{
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -36,7 +32,6 @@ class DialogDownloadDecryptedBackup
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._open = true;
this._params = params;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -65,7 +60,7 @@ class DialogDownloadDecryptedBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<p>
@@ -110,11 +105,7 @@ class DialogDownloadDecryptedBackup
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
@@ -145,7 +136,6 @@ class DialogDownloadDecryptedBackup
this._agentId,
this._encryptionKey
);
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
@@ -165,7 +155,6 @@ class DialogDownloadDecryptedBackup
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
this._updateDirtyState(this._encryptionKey);
}
private get _agentId() {
@@ -28,7 +28,6 @@ import {
fetchBackupConfig,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "../components/config/ha-backup-config-data";
@@ -60,10 +59,7 @@ const STEPS = ["data", "sync"] as const;
const DISALLOWED_AGENTS_NO_HA = [CLOUD_AGENT];
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup
extends DirtyStateProviderMixin<FormData>()(LitElement)
implements HassDialog
{
class DialogGenerateBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _step?: "data" | "sync";
@@ -83,8 +79,6 @@ class DialogGenerateBackup
this._formData = INITIAL_DATA;
this._params = _params;
this._open = true;
this._initDirtyTracking({ type: "deep" }, INITIAL_DATA);
this._updateDirtyState(this._formData);
this._fetchAgents();
this._fetchBackupConfig();
@@ -166,7 +160,6 @@ class DialogGenerateBackup
agents_mode: "custom",
agent_ids: filteredAgents,
};
this._updateDirtyState(this._formData);
}
}
}
@@ -187,11 +180,7 @@ class DialogGenerateBackup
const selectedAgents = this._formData.agent_ids;
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-dialog .open=${this._open} @closed=${this._dialogClosed}>
<ha-dialog-header slot="header">
${isFirstStep
? html`
@@ -287,7 +276,6 @@ class DialogGenerateBackup
...this._formData!,
data,
};
this._updateDirtyState(this._formData);
}
private _renderSync() {
@@ -382,7 +370,6 @@ class DialogGenerateBackup
...this._formData!,
agents_mode: value,
};
this._updateDirtyState(this._formData);
}
private _agentsChanged(ev) {
@@ -390,7 +377,6 @@ class DialogGenerateBackup
...this._formData!,
agent_ids: ev.detail.value,
};
this._updateDirtyState(this._formData);
}
private _nameChanged(ev: InputEvent) {
@@ -398,7 +384,6 @@ class DialogGenerateBackup
...this._formData!,
name: (ev.target as HaInput).value ?? "",
};
this._updateDirtyState(this._formData);
}
private _disabledAgentIds() {
@@ -444,7 +429,6 @@ class DialogGenerateBackup
}
this._params!.submit?.(params);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -13,7 +13,6 @@ import type {
} from "../../../../components/ha-form/types";
import { extractApiErrorMessage } from "../../../../data/hassio/common";
import { changeMountOptions } from "../../../../data/supervisor/mounts";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LocalBackupLocationDialogParams } from "./show-dialog-local-backup-location";
@@ -26,14 +25,8 @@ const SCHEMA = [
},
] as const satisfies HaFormSchema[];
interface LocalBackupLocationFormState {
default_backup_mount: string | null | undefined;
}
@customElement("dialog-local-backup-location")
class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocationFormState>()(
LitElement
) {
class LocalBackupLocationDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogParams?: LocalBackupLocationDialogParams;
@@ -51,10 +44,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
): Promise<void> {
this._dialogParams = dialogParams;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{ default_backup_mount: undefined }
);
}
public closeDialog(): void {
@@ -79,7 +68,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
header-title=${this.hass.localize(
`ui.panel.config.backup.dialogs.local_backup_location.title`
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error
@@ -113,7 +102,7 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._waiting || !this.isDirtyState}
.disabled=${this._waiting || !this._data}
slot="primaryAction"
@click=${this._changeMount}
>
@@ -136,9 +125,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._data = {
default_backup_mount: newLocation === "/backup" ? null : newLocation,
};
this._updateDirtyState({
default_backup_mount: this._data.default_backup_mount,
});
}
private async _changeMount() {
@@ -154,7 +140,6 @@ class LocalBackupLocationDialog extends DirtyStateProviderMixin<LocalBackupLocat
this._waiting = false;
return;
}
this._markDirtyStateClean();
this.closeDialog();
}
@@ -52,6 +52,7 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
@@ -21,7 +21,6 @@ import {
type BackupUploadFileFormData,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@@ -29,7 +28,7 @@ import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
@customElement("ha-dialog-upload-backup")
export class DialogUploadBackup
extends DirtyStateProviderMixin<BackupUploadFileFormData>()(LitElement)
extends LitElement
implements HassDialog<UploadBackupDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -48,8 +47,6 @@ export class DialogUploadBackup
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, INITIAL_UPLOAD_FORM_DATA);
this._updateDirtyState(this._formData);
}
private _dialogClosed() {
@@ -67,6 +64,10 @@ export class DialogUploadBackup
return true;
}
private _formValid() {
return this._formData?.file !== undefined;
}
protected render() {
if (!this._params || !this._formData) {
return nothing;
@@ -78,7 +79,7 @@ export class DialogUploadBackup
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
.preventScrimClose=${this.isDirtyState || this._uploading}
?prevent-scrim-close=${this._uploading}
@closed=${this._dialogClosed}
>
${this._error
@@ -111,7 +112,7 @@ export class DialogUploadBackup
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this.isDirtyState || this._uploading}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action"
@@ -130,13 +131,11 @@ export class DialogUploadBackup
...this._formData!,
file,
};
this._updateDirtyState(this._formData);
}
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._updateDirtyState(this._formData);
}
private async _upload() {
@@ -162,7 +161,6 @@ export class DialogUploadBackup
try {
await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.();
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
@@ -84,10 +84,7 @@ export class CloudRegister extends LitElement {
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
<a href="https://www.nabucasa.com" target="_blank"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
@@ -37,12 +37,6 @@ class HaPanelDevStateRenderer extends LitElement {
@property({ attribute: false })
public showAttributes = true;
@property({ attribute: false })
public showDevice = true;
@property({ attribute: false })
public showArea = true;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -63,15 +57,11 @@ class HaPanelDevStateRenderer extends LitElement {
protected render() {
const showAttributes = !this.narrow && this.showAttributes;
const showDevice = !this.narrow && this.showDevice;
const showArea = !this.narrow && this.showArea;
return html`
<div
class=${classMap({
entities: true,
"hide-attributes": !showAttributes,
"hide-device": !showDevice,
"hide-area": !showArea,
"hide-extra": this.narrow,
})}
role="table"
@@ -91,14 +81,14 @@ class HaPanelDevStateRenderer extends LitElement {
)}
</span>
</div>
<div class="header" role="columnheader" ?hidden=${!showDevice}>
<div class="header" role="columnheader">
<span class="padded">
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</span>
</div>
<div class="header" role="columnheader" ?hidden=${!showArea}>
<div class="header" role="columnheader">
<span class="padded">
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</span>
@@ -365,24 +355,6 @@ class HaPanelDevStateRenderer extends LitElement {
white-space: pre-wrap;
}
.hide-device .filter-devices {
display: none;
}
.hide-device .row .header:nth-child(3),
.hide-device .row .cell:nth-child(3) {
display: none;
}
.hide-area .filter-areas {
display: none;
}
.hide-area .row .header:nth-child(4),
.hide-area .row .cell:nth-child(4) {
display: none;
}
.hide-attributes .filter-attributes {
display: none;
}
@@ -87,18 +87,6 @@ class HaPanelDevState extends LitElement {
})
private _showAttributes = true;
@storage({
key: "devToolsShowDevice",
state: true,
})
private _showDevice = true;
@storage({
key: "devToolsShowArea",
state: true,
})
private _showArea = true;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state()
@@ -169,32 +157,14 @@ class HaPanelDevState extends LitElement {
)}
</h1>
${!this.narrow
? html`
<div class="filters-toggles">
<ha-checkbox
.checked=${this._showDevice}
@change=${this._saveDeviceCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</ha-checkbox>
<ha-checkbox
.checked=${this._showArea}
@change=${this._saveAreaCheckboxState}
>
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</ha-checkbox>
</div>
<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>
`
? html`<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>`
: nothing}
</div>
<ha-expansion-panel
@@ -310,8 +280,6 @@ class HaPanelDevState extends LitElement {
.entities=${entities}
.virtualize=${entities.length > VIRTUALIZE_THRESHOLD}
.showAttributes=${this._showAttributes}
.showDevice=${this._showDevice}
.showArea=${this._showArea}
@states-tool-entity-selected=${this._entitySelected}
>
<ha-input-search
@@ -625,14 +593,6 @@ class HaPanelDevState extends LitElement {
this._showAttributes = ev.target.checked;
}
private _saveDeviceCheckboxState(ev) {
this._showDevice = ev.target.checked;
}
private _saveAreaCheckboxState(ev) {
this._showArea = ev.target.checked;
}
private _yamlChanged(ev) {
this._stateAttributes = ev.detail.value;
this._validJSON = ev.detail.isValid;
@@ -657,25 +617,12 @@ class HaPanelDevState extends LitElement {
.heading {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--ha-space-4);
justify-content: space-between;
}
.heading h1 {
margin-right: auto;
}
.filters-toggles {
display: flex;
align-items: center;
gap: var(--ha-space-4);
}
.heading .filters-toggles ha-checkbox {
margin-right: 0;
width: max-content;
display: inline-flex;
.heading ha-checkbox {
margin-right: var(--ha-space-2);
justify-content: center;
}
.entity-id {
+178 -188
View File
@@ -38,7 +38,6 @@ import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl";
import { groupBy } from "../../../common/util/group-by";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -176,8 +175,6 @@ export interface DeviceAlert {
const DEVICE_ALERTS_INTERVAL = 30000;
const MAX_COLUMNS = 3;
@customElement("ha-config-device-page")
export class HaConfigDevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -214,8 +211,6 @@ export class HaConfigDevicePage extends LitElement {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _integrations = memoizeOne(
(
device: DeviceRegistryEntry,
@@ -754,176 +749,6 @@ export class HaConfigDevicePage extends LitElement {
`
: "";
const infoColumn = html`
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}> ${alert.text} </ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes("warning")
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target ? "noreferrer" : undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
`;
const entitiesColumn = html`
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
`;
const logbookColumn = isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">${this.hass.localize("panel.logbook")}</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: nothing;
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn, relatedCard], [entitiesColumn], [logbookColumn]]
: columns === 2
? [[infoColumn, relatedCard, logbookColumn], [entitiesColumn]]
: [[infoColumn, entitiesColumn, relatedCard, logbookColumn]];
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
@@ -971,7 +796,7 @@ export class HaConfigDevicePage extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
<div class="container">
<div class="header fullwidth">
${area
? html`<div class="header-name">
@@ -1024,9 +849,175 @@ export class HaConfigDevicePage extends LitElement {
: ""}
</div>
</div>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="column">
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}>
${alert.text}
</ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes(
"warning"
)
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target
? "noreferrer"
: undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
${!this.narrow ? relatedCard : ""}
</div>
<div class="column">
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
</div>
<div class="column">
${this.narrow ? relatedCard : ""}
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize("panel.logbook")}
</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>`;
}
@@ -1636,17 +1627,11 @@ export class HaConfigDevicePage extends LitElement {
return [
haStyle,
css`
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
max-width: 1000px;
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
}
@@ -1707,11 +1692,12 @@ export class HaConfigDevicePage extends LitElement {
.column,
.fullwidth {
padding: var(--ha-space-2);
box-sizing: border-box;
}
.column {
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
width: 100%;
@@ -1753,6 +1739,10 @@ export class HaConfigDevicePage extends LitElement {
margin-top: var(--ha-space-4);
}
:host([narrow]) .column {
width: 100%;
}
a {
text-decoration: none;
color: var(--primary-color);
@@ -452,12 +452,6 @@ export class HaConfigDeviceDashboard extends LitElement {
outputDevices = outputDevices.filter((device) => !device.disabled_by);
}
// Build a label lookup once instead of scanning labelReg for every
// label of every device.
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -468,7 +462,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const labels = labelReg && device?.labels;
const labelsEntries = (labels || [])
.map((lbl) => labelLookup!.get(lbl))
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const { areaName } = computeDeviceAreaLabel(
@@ -894,17 +894,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
slot="end"
@click=${this._restoreEntityId}
.path=${mdiRestore}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.restore_entity_id"
)}
></ha-icon-button>
<ha-icon-button
slot="end"
@click=${this._copyEntityId}
.path=${mdiContentCopy}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.copy_entity_id"
)}
></ha-icon-button>
</ha-input>
${!this.entry.device_id
@@ -1178,17 +1178,9 @@ export class HaConfigEntities extends LitElement {
return;
}
// Only the *set* of entity ids matters for the list below. A plain state
// value change on an existing entity cannot add an "entity without unique
// id", so detecting a newly added entity lets us skip the (potentially
// large) rebuild on every state update, which fires constantly.
const stateEntityAdded =
changedProps.has("hass") &&
(!oldHass ||
Object.keys(this.hass.states).some((id) => !(id in oldHass.states)));
if (
stateEntityAdded ||
(changedProps.has("hass") &&
(!oldHass || oldHass.states !== this.hass.states)) ||
changedProps.has("_entities") ||
changedProps.has("_entitySources") ||
changedProps.has("_exposedEntities")
@@ -18,7 +18,6 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import { createInputBoolean } from "../../../data/input_boolean";
@@ -102,9 +101,7 @@ const HELPERS: HelperCreators = {
};
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends DirtyStateProviderMixin<
Helper | undefined
>()(LitElement) {
export class DialogHelperDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: Helper;
@@ -140,7 +137,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
}
this._open = true;
await this.updateComplete;
@@ -297,7 +293,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${this._domain
? this.hass.localize(
"ui.panel.config.helpers.dialog.create_platform",
@@ -369,7 +364,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState(this._item);
}
private async _createItem(): Promise<void> {
@@ -389,7 +383,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err.message || "Unknown error";
@@ -417,7 +410,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
@@ -1259,14 +1259,11 @@ ${rejected
return;
}
// Use a Set for O(1) lookups: this runs on every state change, and the
// filter scans every state, so an array `includes` here is O(states ×
// sources).
const entityIds = new Set(Object.keys(this._entitySource));
const entityIds = Object.keys(this._entitySource);
const newHelpers = Object.values(this.hass!.states).filter(
(entity) =>
entityIds.has(entity.entity_id) ||
entityIds.includes(entity.entity_id) ||
isHelperDomain(computeStateDomain(entity))
);
+19 -20
View File
@@ -13,10 +13,12 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-logo-svg";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import type { HassioInfo } from "../../../data/hassio/supervisor";
@@ -198,8 +200,8 @@ class HaConfigInfo extends LitElement {
</ha-card>
<ha-card outlined class="pages">
<ha-list-base>
<ha-list-item-button @click=${this._showShortcuts}>
<ha-md-list>
<ha-md-list-item type="button" @click=${this._showShortcuts}>
<div
slot="start"
class="icon-background"
@@ -207,14 +209,15 @@ class HaConfigInfo extends LitElement {
>
<ha-svg-icon .path=${mdiKeyboard}></ha-svg-icon>
</div>
<span slot="headline"
<span
>${this.hass.localize("ui.panel.config.info.shortcuts")}</span
>
</ha-list-item-button>
</ha-md-list-item>
${PAGES.map(
(page) => html`
<ha-list-item-button
<ha-md-list-item
type="link"
.href=${documentationUrl(this.hass, page.path)}
target="_blank"
rel="noopener noreferrer"
@@ -222,20 +225,20 @@ class HaConfigInfo extends LitElement {
<div
slot="start"
class="icon-background"
style=${`background-color: ${page.iconColor};`}
.style="background-color: ${page.iconColor}"
>
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
</div>
<span slot="headline">
<span>
${this.hass.localize(
`ui.panel.config.info.items.${page.name}`
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
</ha-md-list-item>
`
)}
</ha-list-base>
</ha-md-list>
${customUiList.length
? html`
<div class="custom-ui">
@@ -243,9 +246,8 @@ class HaConfigInfo extends LitElement {
${customUiList.map(
(item) => html`
<div>
<a href=${item.url} target="_blank" rel="noreferrer">
${item.name}</a
>: ${item.version}
<a href=${item.url} target="_blank"> ${item.name}</a>:
${item.version}
</div>
`
)}
@@ -389,15 +391,12 @@ class HaConfigInfo extends LitElement {
.icon-background ha-svg-icon {
height: 24px;
width: 24px;
display: block;
padding: 8px;
color: #fff;
}
.icon-background {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-circle);
}
@@ -354,7 +354,6 @@ export class HaConfigEntryRow extends LitElement {
<a
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
rel="noreferrer"
@click=${this._signUrl}
>
<ha-dropdown-item value="diagnostics">
@@ -27,6 +27,7 @@ import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import type { ConfigEntry } from "../../../data/config_entries";
import { getConfigEntries } from "../../../data/config_entries";
import { fetchDiagnosticHandlers } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
@@ -162,6 +163,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@state() private _filter: string = history.state?.filter || "";
@state() private _diagnosticHandlers?: Record<string, boolean>;
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@query("ha-input-search") private _searchInput!: HaInputSearch;
@@ -383,6 +386,16 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
this._handleRouteChanged();
this._scanUSBDevices();
this._scanImprovDevices();
if (isComponentLoaded(this.hass.config, "diagnostics")) {
fetchDiagnosticHandlers(this.hass).then((infos) => {
const handlers = {};
for (const info of infos) {
handlers[info.domain] = info.handlers.config_entry;
}
this._diagnosticHandlers = handlers;
});
}
}
protected updated(changed: PropertyValues<this>) {
@@ -637,6 +650,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
.domainEntities=${this._domainEntities[domain] || []}
.supportsDiagnostics=${this._diagnosticHandlers
? this._diagnosticHandlers[domain]
: false}
.logInfo=${this._logInfos
? this._logInfos[domain]
: nothing}
@@ -38,6 +38,9 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false })
public entityRegistryEntries!: EntityRegistryEntry[];
@property({ attribute: "supports-diagnostics", type: Boolean })
public supportsDiagnostics = false;
@property({ attribute: false }) public logInfo?: IntegrationLogInfo;
@property({ attribute: false }) public domainEntities: string[] = [];
@@ -283,7 +283,7 @@ class DialogMatterAddDevice extends LitElement {
const savedStep = this._step;
try {
this._step = "commissioning";
await commissionMatterDevice(this.hass, code, true);
await commissionMatterDevice(this.hass, code);
} catch (_err) {
showToast(this, {
message: this.hass.localize(
@@ -211,7 +211,7 @@ class MatterOptionsPage extends LitElement {
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await commissionMatterDevice(this.hass, code, false);
await commissionMatterDevice(this.hass, code);
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
@@ -92,7 +92,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
this._configEntryId || ""
)}
target="_blank"
rel="noreferrer"
@click=${this._signUrl}
>
<ha-dropdown-item>
@@ -6,16 +6,13 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/dashboard-strategy-editor/hui-dashboard-strategy-element-editor";
import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy";
@customElement("dialog-lovelace-dashboard-configure-strategy")
export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardConfigureStrategyDialogParams;
@@ -32,7 +29,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
this._params = params;
this._data = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -53,7 +49,7 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
@@ -84,7 +80,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
private _handleConfigChanged(ev: CustomEvent): void {
this._data = ev.detail.config;
this._updateDirtyState(this._data!);
}
private async _save() {
@@ -97,7 +92,6 @@ export class DialogLovelaceDashboardConfigureStrategy extends DirtyStateProvider
strategy: this._data,
});
this._submitting = false;
this._markDirtyStateClean();
this.closeDialog();
}
@@ -14,16 +14,13 @@ import type {
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -45,7 +42,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
@@ -55,12 +51,9 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
}
this._updateDirtyState(this._data!);
}
}
@@ -79,8 +72,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return nothing;
}
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const cancelButton = html`
@@ -104,11 +95,11 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${yamlMode
${this._params.dashboard?.mode === "yaml"
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
@@ -151,12 +142,10 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
<ha-button
slot="primaryAction"
@click=${this._updateDashboard}
.disabled=${!yamlMode &&
((this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting ||
!this.isDirtyState)}
?autofocus=${yamlMode}
.disabled=${(this._error && "url_path" in this._error) ||
titleInvalid ||
this._submitting}
?autofocus=${this._params.dashboard?.mode === "yaml"}
>
${this._params.urlPath
? this._params.dashboard?.mode === "storage"
@@ -262,7 +251,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
} else {
this._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -282,7 +270,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
@@ -305,7 +292,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
this._data as LovelaceDashboardCreateParams
);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
let localizedErrorMessage: string | undefined;
@@ -13,7 +13,6 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/ha-dialog";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { PanelMutableParams } from "../../../../data/panel";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { PanelDetailDialogParams } from "./show-dialog-panel-detail";
@@ -26,9 +25,7 @@ interface PanelDetailData {
}
@customElement("dialog-panel-detail")
export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>()(
LitElement
) {
export class DialogPanelDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PanelDetailDialogParams;
@@ -51,7 +48,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
show_in_sidebar: params.showInSidebar,
};
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -74,7 +70,7 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.edit_panel"
)}
@@ -118,7 +114,7 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
<ha-button
slot="primaryAction"
@click=${this._updatePanel}
.disabled=${titleInvalid || this._submitting || !this.isDirtyState}
.disabled=${titleInvalid || this._submitting}
>
${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
@@ -175,7 +171,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
this._data = ev.detail.value;
this._updateDirtyState(this._data!);
}
private async _handleError(err: any) {
@@ -233,7 +228,6 @@ export class DialogPanelDetail extends DirtyStateProviderMixin<PanelDetailData>(
if (Object.keys(updates).length > 0) {
await this._params!.updatePanel(updates);
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._handleError(err);
@@ -9,7 +9,6 @@ import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceResourcesMutableParams } from "../../../../data/lovelace/resource";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceResourceDetailsDialogParams } from "./show-dialog-lovelace-resource-detail";
@@ -31,9 +30,7 @@ const detectResourceType = (url?: string) => {
};
@customElement("dialog-lovelace-resource-detail")
export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
Partial<LovelaceResourcesMutableParams>
>()(LitElement) {
export class DialogLovelaceResourceDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceResourceDetailsDialogParams;
@@ -60,7 +57,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
};
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -87,7 +83,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${dialogTitle}
@closed=${this._dialogClosed}
>
@@ -123,10 +119,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
<ha-button
slot="primaryAction"
@click=${this._updateResource}
.disabled=${urlInvalid ||
!this._data?.res_type ||
this._submitting ||
!this.isDirtyState}
.disabled=${urlInvalid || !this._data?.res_type || this._submitting}
>
${this._params.resource
? this.hass!.localize(
@@ -210,7 +203,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
if (!this._data!.res_type) {
const type = detectResourceType(this._data!.url);
if (!type) {
this._updateDirtyState(this._data!);
return;
}
this._data = {
@@ -218,7 +210,6 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
res_type: type,
};
}
this._updateDirtyState(this._data!);
}
private async _updateResource() {
@@ -235,8 +226,7 @@ export class DialogLovelaceResourceDetail extends DirtyStateProviderMixin<
this._data! as LovelaceResourcesMutableParams
);
}
this._markDirtyStateClean();
this.closeDialog();
this._params = undefined;
} catch (err: any) {
this._error = { base: err?.message || "Unknown error" };
} finally {
@@ -13,7 +13,6 @@ import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/input/ha-input";
import "../../../components/item/ha-row-item";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { adminChangeUsername } from "../../../data/auth";
import type { PersonMutableParams } from "../../../data/person";
import type { User } from "../../../data/user";
@@ -45,20 +44,8 @@ const cropOptions: CropOptions = {
aspectRatio: 1,
};
interface PersonFormState {
name: string;
picture: string | null;
userId: string | undefined;
deviceTrackers: string[];
isAdmin: boolean | undefined;
localOnly: boolean | undefined;
}
@customElement("dialog-person-detail")
class DialogPersonDetail
extends DirtyStateProviderMixin<PersonFormState>()(LitElement)
implements HassDialog
{
class DialogPersonDetail extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -117,21 +104,9 @@ class DialogPersonDetail
this._picture = null;
}
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
}
private _currentState(): PersonFormState {
return {
name: this._name,
picture: this._picture,
userId: this._userId,
deviceTrackers: this._deviceTrackers,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
};
}
public closeDialog() {
this._open = false;
return true;
@@ -159,7 +134,7 @@ class DialogPersonDetail
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this._params.entry
? this._params.entry.name
: this.hass!.localize("ui.panel.config.person.detail.new_person")}
@@ -287,7 +262,7 @@ class DialogPersonDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting || !this.isDirtyState}
.disabled=${nameInvalid || this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -392,17 +367,14 @@ class DialogPersonDetail
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HTMLInputElement).value;
this._updateDirtyState(this._currentState());
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._updateDirtyState(this._currentState());
}
private async _allowLoginChanged(ev): Promise<void> {
@@ -421,7 +393,6 @@ class DialogPersonDetail
this._userId = user.id;
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
this._localOnly = user.local_only;
this._updateDirtyState(this._currentState());
}
},
name: this._name,
@@ -450,20 +421,17 @@ class DialogPersonDetail
this._user = undefined;
this._isAdmin = undefined;
this._localOnly = undefined;
this._updateDirtyState(this._currentState());
}
}
private _deviceTrackersChanged(ev: ValueChangedEvent<string[]>) {
this._error = undefined;
this._deviceTrackers = ev.detail.value;
this._updateDirtyState(this._currentState());
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
this._updateDirtyState(this._currentState());
}
private async _changePassword() {
@@ -559,7 +527,6 @@ class DialogPersonDetail
await this._params!.createEntry?.(values);
this._personExists = true;
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
@@ -22,7 +22,6 @@ import "../../category/ha-category-picker";
import type { GenDataTaskResult } from "../../../../data/ai_task";
import type { SceneConfig } from "../../../../data/scene";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
@@ -43,16 +42,8 @@ const SUGGESTION_INCLUDE: MetadataSuggestionInclude = {
labels: true,
};
interface SceneSaveState {
name?: string;
icon?: string;
entryUpdates: EntityRegistryUpdate;
}
@customElement("ha-dialog-scene-save")
class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
LitElement
) {
class DialogSceneSave extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -89,15 +80,6 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
this._entryUpdates.category ? "category" : "",
this._entryUpdates.labels.length > 0 ? "labels" : "",
].filter(Boolean);
this._initDirtyTracking(
{ type: "deep" },
{
name: this._newName,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
}
);
}
public closeDialog() {
@@ -206,7 +188,6 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
<ha-dialog
.open=${this._open}
header-title=${this._params.title || title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._params.hideInputs
@@ -249,11 +230,7 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.id && !this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize(
this._params.config.id && !this._params.onDiscard
? "ui.panel.config.scene.editor.rename"
@@ -271,27 +248,17 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
this._visibleOptionals = [...this._visibleOptionals, option];
}
private _trackDirtyState() {
this._updateDirtyState({
name: this._newName,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
});
}
private _registryEntryChanged(ev) {
ev.stopPropagation();
const id: string = ev.target.id;
const value = ev.detail.value;
this._entryUpdates = { ...this._entryUpdates, [id]: value };
this._trackDirtyState();
}
private _iconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._newIcon = ev.detail.value || undefined;
this._trackDirtyState();
}
private _valueChanged(ev: CustomEvent) {
@@ -300,7 +267,6 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
if (this._error && this._newName.trim()) {
this._error = false;
}
this._trackDirtyState();
}
private _handleDiscard() {
@@ -359,7 +325,6 @@ class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
this._trackDirtyState();
}
private async _save(): Promise<void> {
+2 -42
View File
@@ -20,24 +20,12 @@ import {
createUser,
deleteUser,
} from "../../../data/user";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AddUserDialogParams } from "./show-dialog-add-user";
interface AddUserFormState {
name?: string;
username?: string;
password?: string;
passwordConfirm?: string;
isAdmin?: boolean;
localOnly?: boolean;
}
@customElement("dialog-add-user")
export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
LitElement
) {
export class DialogAddUser extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
@@ -82,18 +70,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
}
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
username: this._username,
password: "",
passwordConfirm: "",
isAdmin: false,
localOnly: false,
}
);
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
@@ -113,7 +89,7 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.users.add_user.caption"
)}
@@ -266,7 +242,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
if (parts.length) {
this._username = parts[0].toLowerCase();
this._publishDirtyState();
}
}
@@ -274,30 +249,16 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
this._error = undefined;
const target = ev.target as HaInput;
this[`_${target.name}`] = target.value;
this._publishDirtyState();
}
private async _adminChanged(ev: Event): Promise<void> {
const target = ev.target as HaSwitch;
this._isAdmin = target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev: Event): void {
const target = ev.target as HaSwitch;
this._localOnly = target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
username: this._username,
password: this._password,
passwordConfirm: this._passwordConfirm,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
});
}
private async _createUser(ev: Event) {
@@ -345,7 +306,6 @@ export class DialogAddUser extends DirtyStateProviderMixin<AddUserFormState>()(
},
];
this._params!.userAddedCallback(user);
this._markDirtyStateClean();
this._close();
}
@@ -9,7 +9,6 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import { adminChangePassword } from "../../../data/auth";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@@ -44,9 +43,7 @@ interface FormData {
}
@customElement("dialog-admin-change-password")
class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
LitElement
) {
class DialogAdminChangePassword extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: AdminChangePasswordDialogParams;
@@ -68,10 +65,7 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
this._userId = params.userId;
this._data = undefined;
this._error = undefined;
this._submitting = false;
this._success = false;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, {});
}
public closeDialog(): void {
@@ -123,7 +117,7 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.users.change_password.caption"
)}
@@ -179,7 +173,6 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
private _valueChanged(ev) {
this._data = ev.detail.value;
this._updateDirtyState(this._data ?? {});
this._validate();
}
@@ -192,7 +185,6 @@ class DialogAdminChangePassword extends DirtyStateProviderMixin<FormData>()(
this._userId!,
this._data.new_password
);
this._markDirtyStateClean();
this._success = true;
} catch (err: any) {
showToast(this, {
+162 -184
View File
@@ -24,23 +24,13 @@ import {
showAlertDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showAdminChangePasswordDialog } from "./show-dialog-admin-change-password";
import type { UserDetailDialogParams } from "./show-dialog-user-detail";
interface UserDetailFormState {
name: string;
isAdmin?: boolean;
localOnly?: boolean;
isActive?: boolean;
}
@customElement("dialog-user-detail")
class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
LitElement
) {
class DialogUserDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@@ -67,15 +57,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
this._localOnly = params.entry.local_only;
this._isActive = params.entry.is_active;
this._open = true;
this._initDirtyTracking(
{ type: "shallow" },
{
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
}
);
await this.updateComplete;
}
@@ -88,164 +69,177 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
header-title=${user.name}
@closed=${this._dialogClosed}
>
<div>
${this._error
? html`<div class="error">${this._error}</div>`
: nothing}
${
this._error
? html`<div class="error">${this._error}</div>`
: nothing
}
<div class="secondary">
${this.hass.localize("ui.panel.config.users.editor.id")}:
${user.id}<br />
</div>
${badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
</div>
`}
<div class="form">
${!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
${
badges.length === 0
? nothing
: html`
<div class="badge-container">
${badges.map(
([icon, label]) => html`
<ha-label>
<ha-svg-icon slot="icon" .path=${icon}></ha-svg-icon>
${label}
</ha-label>
`
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
</div>
`
: nothing}
${!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-row-item>
${!this._isAdmin && !user.system_generated
}
<div class="form">
${
!user.system_generated
? html`
<ha-input
autofocus
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass!.localize(
"ui.panel.config.users.editor.name"
)}
></ha-input>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.username"
)}</span
>
<span slot="supporting-text">${user.username}</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changeUsername}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_username"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
${
!user.system_generated && this.hass.user?.is_owner
? html`
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.password"
)}</span
>
<span slot="supporting-text">************</span>
${this.hass.user?.is_owner
? html`
<ha-icon-button
slot="end"
.path=${mdiPencil}
@click=${this._changePassword}
.label=${this.hass.localize(
"ui.panel.config.users.editor.change_password"
)}
>
</ha-icon-button>
`
: nothing}
</ha-row-item>
`
: nothing
}
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.active"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.active_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isActive}
@change=${this._activeChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.local_access_only_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated}
.checked=${this._localOnly}
@change=${this._localOnlyChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.users.editor.admin"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.users.editor.admin_description"
)}</span
>
<ha-switch
slot="end"
.disabled=${user.system_generated || user.is_owner}
.checked=${this._isAdmin}
@change=${this._adminChanged}
></ha-switch>
</ha-switch>
</ha-row-item>
${
!this._isAdmin && !user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.users_privileges_note"
)}
</ha-alert>
`
: nothing
}
</div>
${
user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.users_privileges_note"
"ui.panel.config.users.editor.system_generated_read_only_users"
)}
</ha-alert>
`
: nothing}
</div>
${user.system_generated
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.users.editor.system_generated_read_only_users"
)}
</ha-alert>
`
: nothing}
: nothing
}
</div>
<ha-dialog-footer slot="footer">
@@ -254,19 +248,18 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
variant="danger"
appearance="plain"
@click=${this._deleteEntry}
.disabled=${this._submitting ||
user.system_generated ||
user.is_owner}
.disabled=${
this._submitting || user.system_generated || user.is_owner
}
>
${this.hass!.localize("ui.panel.config.users.editor.delete_user")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!this._name ||
this._submitting ||
user.system_generated ||
!this.isDirtyState}
.disabled=${
!this._name || this._submitting || user.system_generated
}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -278,31 +271,18 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
private _nameChanged(ev: InputEvent) {
this._error = undefined;
this._name = (ev.target as HaInput).value ?? "";
this._publishDirtyState();
}
private _adminChanged(ev): void {
this._isAdmin = ev.target.checked;
this._publishDirtyState();
}
private _localOnlyChanged(ev): void {
this._localOnly = ev.target.checked;
this._publishDirtyState();
}
private _activeChanged(ev): void {
this._isActive = ev.target.checked;
this._publishDirtyState();
}
private _publishDirtyState(): void {
this._updateDirtyState({
name: this._name,
isAdmin: this._isAdmin,
localOnly: this._localOnly,
isActive: this._isActive,
});
}
private async _updateEntry() {
@@ -316,7 +296,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
],
local_only: this._localOnly,
});
this._markDirtyStateClean();
this._close();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -329,7 +308,6 @@ class DialogUserDetail extends DirtyStateProviderMixin<UserDetailFormState>()(
this._submitting = true;
try {
if (await this._params!.removeEntry()) {
this._markDirtyStateClean();
this._close();
}
} finally {
@@ -11,7 +11,6 @@ import "../../../components/ha-form/ha-form";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type {
AssistPipeline,
AssistPipelineMutableParams,
@@ -29,9 +28,7 @@ import type { VoiceAssistantPipelineDetailsDialogParams } from "./show-dialog-vo
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("dialog-voice-assistant-pipeline-detail")
export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
Partial<AssistPipeline>
>()(LitElement) {
export class DialogVoiceAssistantPipelineDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VoiceAssistantPipelineDetailsDialogParams;
@@ -65,7 +62,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
this._hideWakeWord =
this._params.hideWakeWord || !this._data.wake_word_entity;
this._initDirtyTracking({ type: "deep" }, this._data);
return;
}
@@ -102,7 +98,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
stt_engine: this._params.pipeline?.stt_engine || sstDefault,
tts_engine: this._params.pipeline?.tts_engine || ttsDefault,
};
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): void {
@@ -150,7 +145,7 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${!this._hideWakeWord ||
@@ -239,7 +234,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
slot="primaryAction"
@click=${this._updatePipeline}
.loading=${this._submitting}
.disabled=${!this.isDirtyState}
>
${isExistingPipeline
? this.hass.localize(
@@ -272,7 +266,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
value[key] = ev.detail.value[key];
});
this._data = { ...this._data, ...value };
this._updateDirtyState(this._data);
}
private async _updatePipeline() {
@@ -306,7 +299,6 @@ export class DialogVoiceAssistantPipelineDetail extends DirtyStateProviderMixin<
// eslint-disable-next-line no-console
console.error("No createPipeline function provided");
}
this._markDirtyStateClean();
this.closeDialog();
} catch (err: any) {
this._error = err?.message || "Unknown error";
@@ -8,7 +8,6 @@ import "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-button";
import type { HomeZoneMutableParams } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
@@ -22,9 +21,7 @@ const SCHEMA = [
];
@customElement("dialog-home-zone-detail")
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
LitElement
) {
class DialogHomeZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -46,7 +43,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
longitude: this.hass.config.longitude,
radius: this.hass.config.radius,
};
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -75,7 +71,7 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
header-title=${this.hass!.localize("ui.common.edit_item", {
name: this._data.name,
})}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -98,7 +94,7 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting || !this.isDirtyState}
.disabled=${!valid || this._submitting}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -124,7 +120,6 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
value.radius = value.location.radius;
delete value.location;
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (): string => "";
+3 -8
View File
@@ -11,15 +11,12 @@ import "../../../components/ha-button";
import type { SchemaUnion } from "../../../components/ha-form/types";
import type { ZoneMutableParams } from "../../../data/zone";
import { getZoneEditorInitData } from "../../../data/zone";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
@customElement("dialog-zone-detail")
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
LitElement
) {
class DialogZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@@ -56,7 +53,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
radius: 100,
};
}
this._initDirtyTracking({ type: "deep" }, this._data);
this._open = true;
}
@@ -97,7 +93,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
name: this._params.entry.name,
})
: this.hass!.localize("ui.panel.config.zone.detail.new_zone")}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-form
@@ -135,7 +131,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting || !this.isDirtyState}
.disabled=${!valid || this._submitting}
>
${this._params.entry
? this.hass!.localize("ui.common.save")
@@ -193,7 +189,6 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
delete value.icon;
}
this._data = value;
this._updateDirtyState(value);
}
private _computeLabel = (
+3 -10
View File
@@ -13,7 +13,6 @@ import type { HaFormSchema } from "../../../components/ha-form/types";
import type { HomeFrontendSystemData } from "../../../data/frontend";
import type { ShortcutItem } from "../../../data/home_shortcuts";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../components/home-favorites-editor";
@@ -38,7 +37,7 @@ const WELCOME_SCHEMA: HaFormSchema[] = [
@customElement("dialog-edit-home")
export class DialogEditHome
extends DirtyStateProviderMixin<EditorState>()(LitElement)
extends LitElement
implements HassDialog<EditHomeDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -61,7 +60,6 @@ export class DialogEditHome
show_welcome_message: !params.config.hide_welcome_message,
shortcuts: params.config.shortcuts ? [...params.config.shortcuts] : [],
};
this._initDirtyTracking({ type: "shallow" }, this._state);
this._open = true;
}
@@ -89,7 +87,7 @@ export class DialogEditHome
.headerSubtitle=${this.hass.localize(
"ui.panel.home.editor.description"
)}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-alert alert-type="info">
@@ -180,7 +178,7 @@ export class DialogEditHome
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -224,7 +222,6 @@ export class DialogEditHome
...this._state!,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _welcomeChanged(
@@ -234,7 +231,6 @@ export class DialogEditHome
...this._state!,
show_welcome_message: ev.detail.value.show_welcome_message,
};
this._updateDirtyState(this._state);
}
private _suggestedChanged(
@@ -244,7 +240,6 @@ export class DialogEditHome
...this._state!,
show_suggested_entities: ev.detail.value.show_suggested_entities,
};
this._updateDirtyState(this._state);
}
private _shortcutsChanged(ev: ValueChangedEvent<ShortcutItem[]>): void {
@@ -252,7 +247,6 @@ export class DialogEditHome
...this._state!,
shortcuts: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private async _save(): Promise<void> {
@@ -276,7 +270,6 @@ export class DialogEditHome
try {
await this._params.saveConfig(config);
this._markDirtyStateClean();
this.closeDialog();
} finally {
this._submitting = false;
@@ -10,14 +10,13 @@ import type { HaFormSchema } from "../../../components/ha-form/types";
import type { CustomShortcutItem } from "../../../data/home_shortcuts";
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { EditShortcutDialogParams } from "./show-dialog-edit-shortcut";
@customElement("dialog-edit-shortcut")
export class DialogEditShortcut
extends DirtyStateProviderMixin<CustomShortcutItem>()(LitElement)
extends LitElement
implements HassDialog<EditShortcutDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -33,7 +32,6 @@ export class DialogEditShortcut
public showDialog(params: EditShortcutDialogParams): void {
this._params = params;
this._data = { ...params.item };
this._initDirtyTracking({ type: "shallow" }, this._data);
this._open = true;
}
@@ -91,7 +89,6 @@ export class DialogEditShortcut
.open=${this._open}
.headerTitle=${this.hass.localize("ui.panel.home.editor.edit_shortcut")}
width="small"
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
@@ -110,11 +107,7 @@ export class DialogEditShortcut
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -131,7 +124,6 @@ export class DialogEditShortcut
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
this._data = ev.detail.value as CustomShortcutItem;
this._updateDirtyState(this._data);
}
private _save() {
@@ -144,7 +136,6 @@ export class DialogEditShortcut
icon: icon || undefined,
color: color || undefined,
});
this._markDirtyStateClean();
this.closeDialog();
}
@@ -267,6 +267,8 @@ function formatTooltip(
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const rows: TemplateResult[] = [];
for (const param of params) {
const y = param.value?.[1] as number;
@@ -278,12 +280,14 @@ function formatTooltip(
if (value === "0") {
continue;
}
// Only the positive bars (consumption) are summed into a total. Negative
// bars mix unrelated categories (grid export and battery charge), so they
// are not totaled.
if (param.componentSubType === "bar" && y > 0) {
sumPositive += y;
countPositive++;
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
}
rows.push(
html`<ha-chart-tooltip-marker
@@ -301,6 +305,8 @@ function formatTooltip(
(row, i) => html`${i > 0 ? html`<br />` : nothing}${row}`
)}${sumPositive !== 0 && countPositive > 1 && formatTotal
? html`<br /><b>${formatTotal(sumPositive)}</b>`
: nothing}${sumNegative !== 0 && countNegative > 1 && formatTotal
? html`<br /><b>${formatTotal(sumNegative)}</b>`
: nothing}`;
}
@@ -32,7 +32,6 @@ import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
import "../../../../components/ha-icon-button";
@@ -190,11 +189,9 @@ export class HuiEnergyDevicesGraphCard
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
click-label-for-more-info
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
</div>
</ha-card>
@@ -546,20 +543,11 @@ export class HuiEnergyDevicesGraphCard
chartData.splice(this._config.max_devices);
}
this._legendData = chartData.map((d) => {
const id = (d as any).id as string;
return {
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
// Untracked is synthetic and external statistics aren't real entities,
// so their labels can't open more-info; fall back to toggling visibility.
noLabelClick:
id === "untracked" ||
isExternalStatistic(id) ||
!(id in this.hass.states),
};
});
this._legendData = chartData.map((d) => ({
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
}));
// filter out hidden stats in place
for (let i = chartData.length - 1; i >= 0; i--) {
if (this._hiddenStats.includes((chartData[i] as any).id)) {
@@ -568,11 +556,8 @@ export class HuiEnergyDevicesGraphCard
}
if (compareData) {
const compareById = new Map(
chartDataCompare.map((d2) => [(d2 as any).id as string, d2] as const)
);
datasets[1].data = chartData.map((d) =>
compareById.get(d.id)
chartDataCompare.find((d2) => (d2 as any).id === d.id)
) as typeof chartDataCompare;
}
@@ -594,11 +579,7 @@ export class HuiEnergyDevicesGraphCard
e.detail.event?.target?.type === "tspan" // label
) {
const id = (e.detail.data as any).id as string;
if (
id !== "untracked" &&
!isExternalStatistic(id) &&
this.hass.states[id]
) {
if (id !== "untracked") {
fireEvent(this, "hass-more-info", {
entityId: id,
});
@@ -606,16 +587,6 @@ export class HuiEnergyDevicesGraphCard
}
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const entityId = ev.detail.id;
if (isExternalStatistic(entityId) || !this.hass.states[entityId]) {
return;
}
fireEvent(this, "hass-more-info", { entityId });
}
private _handleChartTypeChange(): void {
if (!this._chartType) {
return;
@@ -51,13 +51,6 @@ const colorPropertyMap = {
used_battery: "--energy-battery-out-color",
};
const stackOrder = {
to_battery: 1,
to_grid: 2,
used_solar: 3,
used_battery: 4,
};
@customElement("hui-energy-usage-graph-card")
export class HuiEnergyUsageGraphCard
extends SubscribeMixin(LitElement)
@@ -181,10 +174,15 @@ export class HuiEnergyUsageGraphCard
}
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale) }
);
total > 0
? this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale) }
)
: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned",
{ num: formatNumber(-total, this.hass.locale) }
);
private _createOptions = memoizeOne(
(
@@ -561,7 +559,7 @@ export class HuiEnergyUsageGraphCard
this._compareStart!
);
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(combinedData).forEach(([type, sources], idx) => {
Object.entries(sources).forEach(([statId, source]) => {
const points: BarSeriesOption["data"] = [];
// Process chart data.
@@ -594,7 +592,12 @@ export class HuiEnergyUsageGraphCard
statisticsMetaData[statId]
),
// @ts-expect-error
order: stackOrder[type] ?? Object.keys(combinedData).length,
order:
type === "used_solar"
? 1
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 2,
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
@@ -236,17 +236,7 @@ export class HuiPowerSourcesGraphCard
};
const now = Date.now();
const seriesData: Record<
string,
{
colorHex: string;
rgb: [number, number, number];
positive: [number, number][];
negative: [number, number][];
}
> = {};
Object.keys(statIds).forEach((key) => {
Object.keys(statIds).forEach((key, keyIndex) => {
if (statIds[key].stats.length) {
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
const rgb = hex2rgb(colorHex);
@@ -271,32 +261,14 @@ export class HuiPowerSourcesGraphCard
}),
trackY
);
seriesData[key] = { colorHex, rgb, positive, negative };
}
});
const pushSeries = (
key: string,
data: [number, number][],
stack: "positive" | "negative",
z: number
) => {
const { colorHex, rgb } = seriesData[key];
datasets.push({
...commonSeriesOptions,
id: stack === "positive" ? key : `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack,
areaStyle: {
color: new LinearGradient(
0,
stack === "positive" ? 0 : 1,
0,
stack === "positive" ? 1 : 0,
[
datasets.push({
...commonSeriesOptions,
id: key,
name: statIds[key].name,
color: colorHex,
stack: "positive",
areaStyle: {
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
@@ -305,32 +277,34 @@ export class HuiPowerSourcesGraphCard
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]
),
},
data,
z,
});
};
// Draw in reverse order so 0 value lines are overwritten
["solar", "battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
}
});
// Draw in reverse order but above positive series
["battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
}
});
Object.keys(statIds).forEach((key) => {
if (seriesData[key]) {
const { colorHex, rgb } = seriesData[key];
]),
},
data: positive,
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
});
if (key !== "solar") {
datasets.push({
...commonSeriesOptions,
id: `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack: "negative",
areaStyle: {
color: new LinearGradient(0, 1, 0, 0, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: negative,
z: 4 - keyIndex, // draw in reverse order but above positive series
});
}
this._legendData!.push({
id: key,
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
@@ -1047,7 +1047,7 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
height: 24px;
padding: 0 4px;
padding: 16px 4px;
}
.deleteItemButton {
@@ -49,9 +49,6 @@ const addEntityId = (entities: Set<string>, entity) => {
};
const addEntities = (entities: Set<string>, obj) => {
if (!obj) {
return;
}
if (obj.entity) {
addEntityId(entities, obj.entity);
}
@@ -12,7 +12,6 @@ import "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -28,9 +27,7 @@ import type { DashboardStrategyEditorDialogParams } from "./show-dialog-dashboar
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
@customElement("dialog-dashboard-strategy-editor")
class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStrategyConfig>()(
LitElement
) {
class DialogDashboardStrategyEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DashboardStrategyEditorDialogParams;
@@ -52,7 +49,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
this._params = params;
this._strategyConfig = params.config.strategy;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._strategyConfig);
await this.updateComplete;
}
@@ -72,7 +68,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
ev.stopPropagation();
this._guiModeAvailable = ev.detail.guiModeAvailable;
this._strategyConfig = ev.detail.config as LovelaceStrategyConfig;
this._updateDirtyState(this._strategyConfig);
}
private _handleGUIModeChanged(ev: HASSDomEvent<GUIModeChangedEvent>): void {
@@ -87,7 +82,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
strategy: this._strategyConfig!,
});
showSaveSuccessToast(this, this.hass);
this._markDirtyStateClean();
this.closeDialog();
}
@@ -143,7 +137,6 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
header-title=${title || "-"}
header-subtitle=${ifDefined(this._params.title)}
width="large"
@@ -202,11 +195,7 @@ class DialogDashboardStrategyEditor extends DirtyStateProviderMixin<LovelaceStra
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -30,7 +30,6 @@ import {
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -54,7 +53,7 @@ const TABS = ["tab-settings", "tab-visibility"] as const;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
extends LitElement
implements HassDialog<EditSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -97,7 +96,6 @@ export class HuiDialogEditSection
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
this._initDirtyTracking({ type: "deep" }, this._config);
}
public closeDialog() {
@@ -161,7 +159,7 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
.preventScrimClose=${this.isDirtyState}
prevent-scrim-close
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
@@ -233,11 +231,7 @@ export class HuiDialogEditSection
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
>
<ha-button slot="primaryAction" @click=${this._save}>
${this.hass!.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -248,7 +242,6 @@ export class HuiDialogEditSection
private _configChanged(ev: CustomEvent): void {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _handleTabChanged(ev: CustomEvent): void {
@@ -406,7 +399,6 @@ export class HuiDialogEditSection
return;
}
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _ignoreKeydown(ev: KeyboardEvent) {
@@ -431,7 +423,6 @@ export class HuiDialogEditSection
);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
this.closeDialog();
}

Some files were not shown because too many files have changed in this diff Show More