mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 12:52:07 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9538890a | |||
| c2adc2b84a | |||
| 2e3cbf6aab | |||
| 82b2a60f32 | |||
| 2eb1811524 | |||
| 04b284159a | |||
| ddce581fdb | |||
| 668a7df5cd | |||
| d7cad1becd | |||
| 1e412ad035 | |||
| 11611cd597 | |||
| 0d545d744b | |||
| f39dab2de5 | |||
| 1527117015 | |||
| 26794560ac | |||
| 976f9de8ad | |||
| 6810bc5412 | |||
| a4ca54b80b | |||
| 07f0ef0ded | |||
| cf89bb32ab | |||
| ec5cbd16d8 | |||
| 926abd7fc5 | |||
| e227bbe9a2 | |||
| f82b0b61e5 | |||
| a62c89ee00 | |||
| 4b6d07134c | |||
| 35829c301e | |||
| a73f587591 | |||
| 2e5f776af7 | |||
| e91cffe27c | |||
| adbca5145c | |||
| 59d5ded6a5 | |||
| daba5dd8be | |||
| 1e3e43ba46 | |||
| e4cc1eaad2 | |||
| 9aa687577f | |||
| 2556707370 | |||
| 854c57c0e0 | |||
| 055076c45e | |||
| d4ec72006d | |||
| 393d6a8a0a | |||
| 4a030884f5 | |||
| f65596cad8 | |||
| 1449c17fd1 | |||
| ce0e6a7665 | |||
| 460dace974 | |||
| 7111d8a8a8 | |||
| b96d1f2809 | |||
| 26bdff9a16 | |||
| 16ac66c1f8 | |||
| 8533dd586b | |||
| 2cfb947c9b | |||
| 466cf2dfb2 | |||
| 193bcad917 | |||
| 52d32aec42 | |||
| 9adb7215ce | |||
| 273967fe70 |
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ 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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # 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@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (!toProcess) {
|
||||
console.error("Unknown category", group.category);
|
||||
if (!group.pages) {
|
||||
if (!group.subsections && !group.pages) {
|
||||
group.pages = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.subsections) {
|
||||
// Listed pages keep their per-subsection order.
|
||||
for (const subsection of group.subsections) {
|
||||
for (const page of subsection.pages) {
|
||||
if (!toProcess.delete(page)) {
|
||||
console.error("Found unreferenced demo", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any remaining pages land in a trailing "Other" subsection.
|
||||
const leftover = Array.from(toProcess).sort();
|
||||
if (leftover.length) {
|
||||
group.subsections.push({ header: "Other", pages: leftover });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any pre-defined groups will not be sorted.
|
||||
if (group.pages) {
|
||||
for (const page of group.pages) {
|
||||
|
||||
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
|
||||
- New categories without a sidebar entry are appended by the generator with their category name as the header.
|
||||
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
|
||||
|
||||
### Subsections
|
||||
|
||||
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
|
||||
|
||||
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
|
||||
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
|
||||
- Listed pages keep their per-subsection order.
|
||||
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
|
||||
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
|
||||
- Use sentence case for subsection headers and follow the content standards below.
|
||||
|
||||
## Markdown Pages
|
||||
|
||||
Use markdown pages for explanations, design guidance, API notes, and copy standards.
|
||||
|
||||
+164
-9
@@ -10,6 +10,10 @@ import {
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
|
||||
// A group may list its pages flat in `pages`, or group them under named
|
||||
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
|
||||
// any pages found in the category but not listed are appended alphabetically
|
||||
// (to a generated "Other" subsection when the group uses subsections).
|
||||
export default [
|
||||
{
|
||||
// This section has no header and so all page links are shown directly in the sidebar
|
||||
@@ -27,31 +31,162 @@ export default [
|
||||
category: "components",
|
||||
icon: mdiPuzzle,
|
||||
header: "Components",
|
||||
subsections: [
|
||||
{
|
||||
header: "Form and selectors",
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Controls and sliders",
|
||||
pages: [
|
||||
"ha-button",
|
||||
"ha-control-button",
|
||||
"ha-progress-button",
|
||||
"ha-switch",
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Overlays",
|
||||
pages: [
|
||||
"ha-dialog",
|
||||
"ha-dialogs",
|
||||
"ha-adaptive-dialog",
|
||||
"ha-adaptive-popover",
|
||||
"ha-dropdown",
|
||||
"ha-tooltip",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Lists and disclosure",
|
||||
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
|
||||
},
|
||||
{
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "lovelace",
|
||||
icon: mdiViewDashboard,
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
// Specify order of pages. Any pages in the category folder but not listed here will
|
||||
// automatically be added after the pages listed here.
|
||||
pages: ["introduction"],
|
||||
subsections: [
|
||||
{
|
||||
header: "Introduction",
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
header: "Entity cards",
|
||||
pages: [
|
||||
"entities-card",
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Picture cards",
|
||||
pages: [
|
||||
"picture-card",
|
||||
"picture-elements-card",
|
||||
"picture-entity-card",
|
||||
"picture-glance-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Domain cards",
|
||||
pages: [
|
||||
"light-card",
|
||||
"thermostat-card",
|
||||
"alarm-panel-card",
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Layout and utility",
|
||||
pages: [
|
||||
"grid-and-stack-card",
|
||||
"conditional-card",
|
||||
"iframe-card",
|
||||
"markdown-card",
|
||||
"todo-list-card",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
icon: mdiInformationOutline,
|
||||
header: "More Info dialogs",
|
||||
subsections: [
|
||||
{
|
||||
header: "Climate and water",
|
||||
pages: ["climate", "humidifier", "water-heater", "fan"],
|
||||
},
|
||||
{
|
||||
header: "Covers and access",
|
||||
pages: ["cover", "lock", "lawn-mower", "vacuum"],
|
||||
},
|
||||
{
|
||||
header: "Lighting",
|
||||
pages: ["light", "scene"],
|
||||
},
|
||||
{
|
||||
header: "Media",
|
||||
pages: ["media-player"],
|
||||
},
|
||||
{
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
icon: mdiRobot,
|
||||
header: "Automation",
|
||||
pages: [
|
||||
"editor-trigger",
|
||||
"editor-condition",
|
||||
"editor-action",
|
||||
"trace",
|
||||
"trace-timeline",
|
||||
subsections: [
|
||||
{
|
||||
header: "Editors",
|
||||
pages: ["editor-trigger", "editor-condition", "editor-action"],
|
||||
},
|
||||
{
|
||||
header: "Descriptions",
|
||||
pages: ["describe-trigger", "describe-condition", "describe-action"],
|
||||
},
|
||||
{
|
||||
header: "Traces",
|
||||
pages: ["trace", "trace-timeline"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +199,26 @@ export default [
|
||||
category: "date-time",
|
||||
icon: mdiCalendarClock,
|
||||
header: "Date and Time",
|
||||
subsections: [
|
||||
{
|
||||
header: "Date",
|
||||
pages: ["date"],
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
pages: ["time", "time-seconds", "time-weekday"],
|
||||
},
|
||||
{
|
||||
header: "Combined",
|
||||
pages: [
|
||||
"date-time",
|
||||
"date-time-numeric",
|
||||
"date-time-seconds",
|
||||
"date-time-short",
|
||||
"date-time-short-year",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
|
||||
+60
-20
@@ -40,15 +40,26 @@ interface GalleryPage {
|
||||
demo?: unknown;
|
||||
}
|
||||
|
||||
interface GallerySidebarSubsection {
|
||||
header: string;
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
interface GallerySidebarGroup {
|
||||
category: string;
|
||||
header?: string;
|
||||
icon?: string;
|
||||
pages: string[];
|
||||
pages?: string[];
|
||||
subsections?: GallerySidebarSubsection[];
|
||||
}
|
||||
|
||||
const groupPages = (group: GallerySidebarGroup): string[] =>
|
||||
group.subsections
|
||||
? group.subsections.flatMap((subsection) => subsection.pages)
|
||||
: (group.pages ?? []);
|
||||
|
||||
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
|
||||
const sidebar: unknown[] = [];
|
||||
|
||||
for (const group of GALLERY_SIDEBAR) {
|
||||
const links: unknown[] = [];
|
||||
const expanded = group.pages.some(
|
||||
const expanded = groupPages(group).some(
|
||||
(page) => this._page === `${group.category}/${page}`
|
||||
);
|
||||
|
||||
for (const page of group.pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
const content = group.subsections
|
||||
? group.subsections.map((subsection) =>
|
||||
this._renderSidebarSubsection(group, subsection)
|
||||
)
|
||||
);
|
||||
}
|
||||
: this._renderPageLinks(group, group.pages ?? []);
|
||||
|
||||
sidebar.push(
|
||||
group.header
|
||||
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${links}
|
||||
${content}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
: links
|
||||
: content
|
||||
);
|
||||
}
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
private _renderSidebarSubsection(
|
||||
group: GallerySidebarGroup,
|
||||
subsection: GallerySidebarSubsection
|
||||
) {
|
||||
return html`
|
||||
<div class="gallery-sidebar-subheader">${subsection.header}</div>
|
||||
${this._renderPageLinks(group, subsection.pages)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
|
||||
const links: unknown[] = [];
|
||||
for (const page of pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
)
|
||||
);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private _renderPageLink(
|
||||
page: string,
|
||||
title: string,
|
||||
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
|
||||
width: var(--ha-sidebar-expanded-section-item-width, 248px);
|
||||
}
|
||||
|
||||
.gallery-sidebar-subheader {
|
||||
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.gallery-sidebar-icon,
|
||||
.gallery-nav-item ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-icon-color);
|
||||
|
||||
+5
-5
@@ -40,7 +40,7 @@
|
||||
"@codemirror/view": "6.43.1",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.8",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
"@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.61.0",
|
||||
"@html-eslint/eslint-plugin": "0.62.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.12",
|
||||
"@rspack/core": "2.0.6",
|
||||
"@rsdoctor/rspack-plugin": "1.5.13",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@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.3",
|
||||
"prettier": "3.8.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
|
||||
@@ -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" | "img", url: string, type?: "module") =>
|
||||
const _load = (tag: "link" | "script", 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,5 +33,4 @@ const _load = (tag: "link" | "script" | "img", 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");
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ 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,
|
||||
@@ -45,17 +43,6 @@ 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}${
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/** An empty image which can be set as src of an img element. */
|
||||
export const emptyImageBase64 =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -19,6 +19,40 @@ 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,
|
||||
@@ -138,21 +172,10 @@ 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 = TYPE_MAP[part.type];
|
||||
const type = MONETARY_TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
@@ -191,7 +214,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
return [{ type: "value", value: value }];
|
||||
}
|
||||
|
||||
if (["date", "input_datetime", "time"].includes(domain)) {
|
||||
if (DATE_TIME_DOMAINS.has(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`.
|
||||
|
||||
@@ -250,23 +273,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
|
||||
// state is a timestamp
|
||||
if (
|
||||
[
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
TIMESTAMP_DOMAINS.has(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { updateIsInstalling } from "../../data/update";
|
||||
|
||||
export const updateIcon = (stateObj: HassEntity, state?: string) => {
|
||||
const compareState = state ?? stateObj.state;
|
||||
return compareState === "on"
|
||||
? updateIsInstalling(stateObj as UpdateEntity)
|
||||
? "mdi:package-down"
|
||||
: "mdi:package-up"
|
||||
: "mdi:package";
|
||||
// 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";
|
||||
};
|
||||
|
||||
@@ -40,6 +40,25 @@ 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.
|
||||
*
|
||||
@@ -75,7 +94,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format !== NumberFormat.none &&
|
||||
!Number.isNaN(Number(num))
|
||||
) {
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
locale,
|
||||
getDefaultFormatOptions(num, options)
|
||||
).formatToParts(Number(num));
|
||||
@@ -87,7 +106,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format === NumberFormat.none
|
||||
) {
|
||||
// If NumberFormat is none, use en-US format without grouping.
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
"en-US",
|
||||
getDefaultFormatOptions(num, {
|
||||
...options,
|
||||
|
||||
@@ -11,6 +11,12 @@ 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;
|
||||
@@ -19,8 +25,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
el.style.top = "0";
|
||||
el.style.left = "0";
|
||||
el.style.opacity = "0";
|
||||
root.appendChild(el);
|
||||
container.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
root.removeChild(el);
|
||||
container.removeChild(el);
|
||||
};
|
||||
|
||||
@@ -394,6 +394,18 @@ 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)"
|
||||
@@ -413,10 +425,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 {
|
||||
@@ -426,9 +438,7 @@ export class HaChartBase extends LitElement {
|
||||
noLabelClick = item.noLabelClick ?? false;
|
||||
}
|
||||
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
|
||||
const dataset =
|
||||
datasets.find((d) => d.id === id) ??
|
||||
datasets.find((d) => d.name === id);
|
||||
const dataset = datasetById.get(id) ?? datasetByName.get(id);
|
||||
itemStyle = {
|
||||
color: dataset?.color as string,
|
||||
...(dataset?.itemStyle as { borderColor?: string }),
|
||||
|
||||
@@ -147,6 +147,14 @@ 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 ||
|
||||
@@ -154,9 +162,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const param = params.find(
|
||||
(p: Record<string, any>) => p.seriesIndex === index
|
||||
);
|
||||
const param = paramsBySeriesIndex.get(index);
|
||||
if (param) {
|
||||
datapoints.push(param);
|
||||
return;
|
||||
@@ -440,6 +446,10 @@ 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) => {
|
||||
@@ -451,9 +461,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
const data: LineSeriesOption[] = [];
|
||||
|
||||
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
||||
const pushData = (timestamp: number, datavalues: any[] | null) => {
|
||||
if (!datavalues) return;
|
||||
if (timestamp > endTime) {
|
||||
if (timestamp > endTimeMs) {
|
||||
// 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;
|
||||
@@ -624,11 +634,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
series.push(targetHigh, targetLow);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
pushData(entityState.last_changed, series);
|
||||
} else {
|
||||
const target = safeParseFloat(entityState.attributes.temperature);
|
||||
series.push(target);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
pushData(entityState.last_changed, series);
|
||||
}
|
||||
});
|
||||
} else if (domain === "humidifier") {
|
||||
@@ -746,31 +756,27 @@ export class StateHistoryChartLine extends LitElement {
|
||||
} else {
|
||||
series.push(entityState.state === "on" ? current : null);
|
||||
}
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
pushData(entityState.last_changed, series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
let lastNullDate: Date | null = null;
|
||||
let lastDate: number;
|
||||
let lastNullDate: number | 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 = new Date(entityState.last_changed);
|
||||
const date = entityState.last_changed;
|
||||
if (value !== null && lastNullDate) {
|
||||
const dateTime = date.getTime();
|
||||
const lastNullDateTime = lastNullDate.getTime();
|
||||
const lastDateTime = lastDate?.getTime();
|
||||
const tmpValue =
|
||||
(value - lastValue) *
|
||||
((lastNullDateTime - lastDateTime) /
|
||||
(dateTime - lastDateTime)) +
|
||||
((lastNullDate - lastDate) / (date - lastDate)) +
|
||||
lastValue;
|
||||
pushData(lastNullDate, [tmpValue]);
|
||||
pushData(new Date(lastNullDateTime + 1), [null]);
|
||||
pushData(lastNullDate + 1, [null]);
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
@@ -809,17 +815,17 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
// Add an entry for final values
|
||||
pushData(endTime, prevValues);
|
||||
pushData(endTimeMs, prevValues);
|
||||
|
||||
// For sensors, append current state if viewing recent data
|
||||
const now = new Date();
|
||||
const nowMs = Date.now();
|
||||
// allow 1s of leeway for "now"
|
||||
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
|
||||
const isUpToNow = nowMs - endTimeMs <= 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([now, currentValue]);
|
||||
data[0].data!.push([nowMs, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,10 +215,16 @@ 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 = this._filteredData?.find((data) => data[this.id] === id);
|
||||
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
|
||||
@@ -183,6 +183,7 @@ 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;
|
||||
|
||||
@@ -12,6 +12,20 @@ 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;
|
||||
@@ -66,14 +80,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
let value: string | undefined = ev.detail.value;
|
||||
|
||||
if (value === this.data) {
|
||||
return;
|
||||
}
|
||||
let value: HaFormSelectData | undefined = ev.detail.value;
|
||||
|
||||
if (value === "") {
|
||||
value = undefined;
|
||||
} else if (value != null) {
|
||||
value = matchSelectOptionValue(this.schema.options, value);
|
||||
}
|
||||
|
||||
if (value === this.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -41,6 +41,8 @@ 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,7 +354,9 @@ export class HaSerialPortSelector extends LitElement {
|
||||
}
|
||||
|
||||
private get _selectorDomain(): string | undefined {
|
||||
return this.context?.handler;
|
||||
// `domain` is the integration domain even in options flows, where the flow
|
||||
// handler is the config entry id instead.
|
||||
return this.context?.domain;
|
||||
}
|
||||
|
||||
private _memoRecommendedDomains = memoizeOne(
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
@@ -11,6 +12,9 @@ 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)
|
||||
);
|
||||
@@ -21,10 +25,11 @@ 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: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: var(--ha-top-app-bar-width, 100%);
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
@@ -116,17 +121,17 @@ export const haTopAppBarFixedStyles = css`
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust {
|
||||
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(
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
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 {
|
||||
@@ -146,6 +151,8 @@ 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;
|
||||
@@ -154,14 +161,13 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
|
||||
@property({ attribute: false })
|
||||
public get scrollTarget(): HTMLElement | Window {
|
||||
return this._scrollTarget || window;
|
||||
return this._scrollTarget || this._scrollElement || 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();
|
||||
@@ -183,7 +189,6 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
if (this.hasUpdated) {
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -243,7 +248,7 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
}
|
||||
|
||||
protected _renderContent() {
|
||||
return html`<div class="top-app-bar-fixed-adjust">
|
||||
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
@@ -252,7 +257,6 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -270,13 +274,6 @@ 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
|
||||
@@ -336,7 +333,10 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
|
||||
};
|
||||
|
||||
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
|
||||
static override styles: CSSResultGroup = [
|
||||
haStyleScrollbar,
|
||||
haTopAppBarFixedStyles,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -85,15 +85,25 @@ export class HaTTSVoicePicker extends LitElement {
|
||||
await listTTSVoices(this.hass, this.engineId, this.language)
|
||||
).voices;
|
||||
|
||||
if (!this.value) {
|
||||
const valueIsValid =
|
||||
this.value &&
|
||||
this._voices?.some((voice) => voice.voice_id === this.value);
|
||||
|
||||
if (valueIsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._voices ||
|
||||
!this._voices.find((voice) => voice.voice_id === this.value)
|
||||
) {
|
||||
this.value = undefined;
|
||||
// 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;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ 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,
|
||||
})}
|
||||
>
|
||||
@@ -130,12 +131,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
|
||||
.top-app-bar-fixed-adjust--pane {
|
||||
display: flex;
|
||||
height: calc(
|
||||
100vh - var(--total-top-app-bar-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane {
|
||||
@@ -167,6 +163,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust--pane .content {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -73,30 +73,44 @@ 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) =>
|
||||
includeEntities.includes(entityId)
|
||||
includeEntitiesSet.has(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeEntities) {
|
||||
const excludeEntitiesSet = new Set(excludeEntities);
|
||||
entityIds = entityIds.filter(
|
||||
(entityId) => !excludeEntities.includes(entityId)
|
||||
(entityId) => !excludeEntitiesSet.has(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDomains) {
|
||||
const includeDomainsSet = new Set(includeDomains);
|
||||
entityIds = entityIds.filter((eid) =>
|
||||
includeDomains.includes(computeDomain(eid))
|
||||
includeDomainsSet.has(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
const excludeDomainsSet = new Set(excludeDomains);
|
||||
entityIds = entityIds.filter(
|
||||
(eid) => !excludeDomains.includes(computeDomain(eid))
|
||||
(eid) => !excludeDomainsSet.has(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];
|
||||
|
||||
@@ -110,12 +124,12 @@ export const getEntities = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domainName = domainToName(hass.localize, computeDomain(entityId));
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
if (domainName === undefined) {
|
||||
domainName = domainToName(hass.localize, domain);
|
||||
domainNames.set(domain, domainName);
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
|
||||
@@ -44,15 +44,6 @@ interface AddonTranslations {
|
||||
configuration?: Record<string, AddonFieldTranslation>;
|
||||
}
|
||||
|
||||
export interface AddonNetworkIsolationParams {
|
||||
interface: string;
|
||||
ipv4: string;
|
||||
}
|
||||
|
||||
export interface AddonNetworkIsolation extends AddonNetworkIsolationParams {
|
||||
driver: "macvlan";
|
||||
}
|
||||
|
||||
export interface HassioAddonInfo {
|
||||
advanced: boolean;
|
||||
available: boolean;
|
||||
@@ -109,9 +100,6 @@ export interface HassioAddonDetails extends HassioAddonInfo {
|
||||
long_description: null | string;
|
||||
machine: any;
|
||||
network_description: null | Record<string, string>;
|
||||
network_isolation: AddonNetworkIsolation | null;
|
||||
network_isolation_available: boolean;
|
||||
network_isolation_mac: string | null;
|
||||
network: null | Record<string, number>;
|
||||
options: Record<string, unknown>;
|
||||
privileged: any;
|
||||
@@ -155,7 +143,6 @@ export interface HassioAddonSetOptionParams {
|
||||
auto_update?: boolean;
|
||||
ingress_panel?: boolean;
|
||||
network?: Record<string, unknown> | null;
|
||||
network_isolation?: AddonNetworkIsolationParams | null;
|
||||
watchdog?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface NetworkInterface {
|
||||
ipv6?: Partial<IpConfiguration>;
|
||||
type: "ethernet" | "wireless" | "vlan";
|
||||
wifi?: Partial<WifiConfiguration> | null;
|
||||
network_isolation_capable?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerNetwork {
|
||||
|
||||
+8
-6
@@ -725,16 +725,18 @@ 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([
|
||||
...historyItem.data.map((d) => d.entity_id),
|
||||
...ltsItem.data.map((d) => d.entity_id),
|
||||
...historyDataByEntity.keys(),
|
||||
...ltsDataByEntity.keys(),
|
||||
]);
|
||||
|
||||
for (const entity of entities) {
|
||||
const historyDataItem = historyItem.data.find(
|
||||
(d) => d.entity_id === entity
|
||||
);
|
||||
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
|
||||
const historyDataItem = historyDataByEntity.get(entity);
|
||||
const ltsDataItem = ltsDataByEntity.get(entity);
|
||||
|
||||
if (!historyDataItem || !ltsDataItem) {
|
||||
newLineItem.data.push(historyDataItem || ltsDataItem!);
|
||||
|
||||
@@ -43,11 +43,6 @@ 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)
|
||||
@@ -159,5 +154,3 @@ export const computeDefaultFavoriteColors = (
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
export const formatTempColor = (value: number) => `${value} K`;
|
||||
|
||||
+18
-17
@@ -231,6 +231,24 @@ 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 &&
|
||||
@@ -241,23 +259,6 @@ 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -403,6 +403,7 @@ 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>
|
||||
|
||||
@@ -35,6 +35,10 @@ 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>;
|
||||
@@ -108,7 +112,7 @@ class StepFlowForm extends LitElement {
|
||||
.computeHelper=${this._helperCallback}
|
||||
.computeError=${this._errorCallback}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.context=${{ handler: step.handler }}
|
||||
.context=${{ handler: step.handler, domain: this.domain }}
|
||||
></ha-form>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -1103,6 +1103,7 @@ 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);
|
||||
|
||||
@@ -22,6 +22,7 @@ interface EntityInfo {
|
||||
entityId: string;
|
||||
entityName: string | undefined;
|
||||
areaId: string | undefined;
|
||||
deviceId: string | undefined;
|
||||
}
|
||||
|
||||
@customElement("more-info-content")
|
||||
@@ -120,7 +121,7 @@ class MoreInfoContent extends LitElement {
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area } = getEntityContext(
|
||||
const { area, device } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
@@ -128,7 +129,8 @@ class MoreInfoContent extends LitElement {
|
||||
hass.floors
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
return { entityId, entityName, areaId };
|
||||
const deviceId = device?.id;
|
||||
return { entityId, entityName, areaId, deviceId };
|
||||
})
|
||||
.filter(Boolean) as EntityInfo[];
|
||||
|
||||
@@ -140,10 +142,20 @@ class MoreInfoContent extends LitElement {
|
||||
const areaIds = new Set(entityInfos.map((info) => info.areaId));
|
||||
const allSameArea = areaIds.size === 1;
|
||||
|
||||
// Build name and state content config based on conditions
|
||||
const name: EntityNameItem[] = [{ type: "device" }];
|
||||
// Check if all entities belong to the same device
|
||||
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
|
||||
const allSameDevice = deviceIds.size === 1;
|
||||
|
||||
if (!allSameEntityName) {
|
||||
// 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) {
|
||||
name.push({ type: "entity" });
|
||||
}
|
||||
|
||||
|
||||
@@ -29,13 +29,11 @@ class SupervisorAppConfigDashboard extends LitElement {
|
||||
const hasConfiguration =
|
||||
(this.addon.options && Object.keys(this.addon.options).length) ||
|
||||
(this.addon.schema && Object.keys(this.addon.schema).length);
|
||||
const hasNetwork =
|
||||
this.addon.network || this.addon.network_isolation_available;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
${this.addon.system_managed &&
|
||||
(hasConfiguration || hasNetwork || this.addon.audio)
|
||||
(hasConfiguration || this.addon.network || this.addon.audio)
|
||||
? html`
|
||||
<supervisor-app-system-managed
|
||||
.hass=${this.hass}
|
||||
@@ -44,7 +42,7 @@ class SupervisorAppConfigDashboard extends LitElement {
|
||||
></supervisor-app-system-managed>
|
||||
`
|
||||
: nothing}
|
||||
${hasConfiguration || hasNetwork || this.addon.audio
|
||||
${hasConfiguration || this.addon.network || this.addon.audio
|
||||
? html`
|
||||
${hasConfiguration
|
||||
? html`
|
||||
@@ -56,7 +54,7 @@ class SupervisorAppConfigDashboard extends LitElement {
|
||||
></supervisor-app-config>
|
||||
`
|
||||
: nothing}
|
||||
${hasNetwork
|
||||
${this.addon.network
|
||||
? html`
|
||||
<supervisor-app-network
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -9,44 +9,22 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-formfield";
|
||||
import "../../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../../../components/ha-switch";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../../components/input/ha-input";
|
||||
import type {
|
||||
AddonNetworkIsolationParams,
|
||||
HassioAddonDetails,
|
||||
HassioAddonSetOptionParams,
|
||||
} from "../../../../../data/hassio/addon";
|
||||
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
|
||||
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
|
||||
import type { NetworkInterface } from "../../../../../data/hassio/network";
|
||||
import { fetchNetworkInfo } from "../../../../../data/hassio/network";
|
||||
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";
|
||||
|
||||
interface NetworkConfig {
|
||||
ports: Record<string, number | null>;
|
||||
isolation: AddonNetworkIsolationParams | null;
|
||||
}
|
||||
|
||||
const isValidIpv4 = (address: string): boolean => {
|
||||
const parts = address.split(".");
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
);
|
||||
};
|
||||
|
||||
@customElement("supervisor-app-network")
|
||||
class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
LitElement
|
||||
) {
|
||||
class SupervisorAppNetwork extends DirtyStateProviderMixin<
|
||||
Record<string, number | null>
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public addon!: HassioAddonDetails;
|
||||
@@ -57,19 +35,17 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _config?: NetworkConfig;
|
||||
|
||||
@state() private _isolationInterfaces?: NetworkInterface[];
|
||||
@state() private _config?: Record<string, number | null>;
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const ports = this._config.ports;
|
||||
const config = this._config;
|
||||
|
||||
const hasHiddenOptions = Object.keys(ports).find(
|
||||
(entry) => ports[entry] === null
|
||||
const hasHiddenOptions = Object.keys(config).find(
|
||||
(entry) => config[entry] === null
|
||||
);
|
||||
|
||||
return html`
|
||||
@@ -80,29 +56,23 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.introduction"
|
||||
)}
|
||||
</p>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
${this.addon.network
|
||||
? html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.introduction"
|
||||
)}
|
||||
</p>
|
||||
<ha-form
|
||||
.disabled=${this.disabled}
|
||||
.data=${ports}
|
||||
@value-changed=${this._configChanged}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
.schema=${this._createSchema(ports, this._showOptional)}
|
||||
></ha-form>
|
||||
`
|
||||
: nothing}
|
||||
${this.addon.network_isolation_available
|
||||
? this._renderIsolation()
|
||||
: nothing}
|
||||
|
||||
<ha-form
|
||||
.disabled=${this.disabled}
|
||||
.data=${this._config}
|
||||
@value-changed=${this._configChanged}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
.schema=${this._createSchema(this._config, this._showOptional)}
|
||||
></ha-form>
|
||||
</div>
|
||||
${hasHiddenOptions
|
||||
? html`<ha-formfield
|
||||
@@ -140,129 +110,21 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIsolation() {
|
||||
const isolation = this._config!.isolation;
|
||||
|
||||
return html`
|
||||
<div class="isolation">
|
||||
<ha-formfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.title"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
.checked=${isolation !== null}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._isolationToggled}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<p class="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.description"
|
||||
)}
|
||||
</p>
|
||||
${isolation
|
||||
? html`
|
||||
<ha-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.interface"
|
||||
)}
|
||||
.value=${isolation.interface}
|
||||
.disabled=${this.disabled}
|
||||
.options=${(this._isolationInterfaces || []).map((iface) => ({
|
||||
value: iface.interface,
|
||||
label: iface.ipv4?.address?.length
|
||||
? `${iface.interface} (${iface.ipv4.address.join(", ")})`
|
||||
: iface.interface,
|
||||
}))}
|
||||
@selected=${this._isolationInterfaceChanged}
|
||||
></ha-select>
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.ip_address"
|
||||
)}
|
||||
.hint=${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.ip_address_helper"
|
||||
)}
|
||||
.value=${isolation.ipv4}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._isolationAddressChanged}
|
||||
></ha-input>
|
||||
${this.addon.network_isolation_mac
|
||||
? html`
|
||||
<p class="mac">
|
||||
<span class="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.mac_address"
|
||||
)}
|
||||
</span>
|
||||
<code>${this.addon.network_isolation_mac}</code>
|
||||
</p>
|
||||
`
|
||||
: nothing}
|
||||
<ha-alert alert-type="info">
|
||||
<ul>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.info.separate_device"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.info.ipv6"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.info.host_reachability"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.info.host_interfaces"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("addon")) {
|
||||
this._setNetworkConfig();
|
||||
if (
|
||||
this.addon.network_isolation_available &&
|
||||
this._isolationInterfaces === undefined
|
||||
) {
|
||||
this._loadIsolationInterfaces();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadIsolationInterfaces(): Promise<void> {
|
||||
this._isolationInterfaces = [];
|
||||
try {
|
||||
const { interfaces } = await fetchNetworkInfo(this.hass);
|
||||
this._isolationInterfaces = interfaces.filter(
|
||||
(iface) => iface.network_isolation_capable
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = extractApiErrorMessage(err);
|
||||
}
|
||||
}
|
||||
|
||||
private _createSchema = memoizeOne(
|
||||
(
|
||||
ports: Record<string, number | null>,
|
||||
config: Record<string, number | null>,
|
||||
showOptional: boolean
|
||||
): HaFormSchema[] =>
|
||||
(showOptional
|
||||
? Object.keys(ports)
|
||||
: Object.keys(ports).filter((entry) => ports[entry] !== null)
|
||||
? Object.keys(config)
|
||||
: Object.keys(config).filter((entry) => config[entry] !== null)
|
||||
).map((entry) => ({
|
||||
name: entry,
|
||||
selector: {
|
||||
@@ -285,58 +147,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
item.name;
|
||||
|
||||
private _setNetworkConfig(): void {
|
||||
const config: NetworkConfig = {
|
||||
ports: this.addon.network || {},
|
||||
isolation: this.addon.network_isolation
|
||||
? {
|
||||
interface: this.addon.network_isolation.interface,
|
||||
ipv4: this.addon.network_isolation.ipv4,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const config = this.addon.network || {};
|
||||
this._config = config;
|
||||
this._initDirtyTracking({ type: "deep" }, config);
|
||||
this._initDirtyTracking({ type: "shallow" }, config);
|
||||
}
|
||||
|
||||
private _configChanged(ev: CustomEvent): void {
|
||||
this._config = { ...this._config!, ports: ev.detail.value };
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _isolationToggled(ev: Event): void {
|
||||
const enabled = (ev.target as HaSwitch).checked;
|
||||
this._config = {
|
||||
...this._config!,
|
||||
isolation: enabled
|
||||
? {
|
||||
interface:
|
||||
this.addon.network_isolation?.interface ||
|
||||
this._isolationInterfaces?.[0]?.interface ||
|
||||
"",
|
||||
ipv4: this.addon.network_isolation?.ipv4 || "",
|
||||
}
|
||||
: null,
|
||||
};
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _isolationInterfaceChanged(ev: HaSelectSelectEvent): void {
|
||||
this._config = {
|
||||
...this._config!,
|
||||
isolation: { ...this._config!.isolation!, interface: ev.detail.value },
|
||||
};
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _isolationAddressChanged(ev: Event): void {
|
||||
this._config = {
|
||||
...this._config!,
|
||||
isolation: {
|
||||
...this._config!.isolation!,
|
||||
ipv4: (ev.target as HaInput).value || "",
|
||||
},
|
||||
};
|
||||
this._updateDirtyState(this._config);
|
||||
this._config = ev.detail.value;
|
||||
this._updateDirtyState(ev.detail.value);
|
||||
}
|
||||
|
||||
private async _resetTapped(ev: CustomEvent): Promise<void> {
|
||||
@@ -345,13 +163,9 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
}
|
||||
|
||||
const button = ev.currentTarget as any;
|
||||
const data: HassioAddonSetOptionParams = {};
|
||||
if (this.addon.network) {
|
||||
data.network = null;
|
||||
}
|
||||
if (this.addon.network_isolation_available) {
|
||||
data.network_isolation = null;
|
||||
}
|
||||
const data: HassioAddonSetOptionParams = {
|
||||
network: null,
|
||||
};
|
||||
|
||||
try {
|
||||
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
|
||||
@@ -389,36 +203,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
const button = ev.currentTarget as any;
|
||||
|
||||
this._error = undefined;
|
||||
const { ports, isolation } = this._config!;
|
||||
const networkconfiguration: Record<string, number | null> = {};
|
||||
Object.entries(this._config!).forEach(([key, value]) => {
|
||||
networkconfiguration[key] = value ?? null;
|
||||
});
|
||||
|
||||
if (this.addon.network_isolation_available && isolation) {
|
||||
if (!isolation.interface) {
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.no_interface"
|
||||
);
|
||||
button.actionError();
|
||||
return;
|
||||
}
|
||||
if (!isValidIpv4(isolation.ipv4)) {
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.apps.configuration.network.isolation.invalid_ip"
|
||||
);
|
||||
button.actionError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data: HassioAddonSetOptionParams = {};
|
||||
if (this.addon.network) {
|
||||
const networkconfiguration: Record<string, number | null> = {};
|
||||
Object.entries(ports).forEach(([key, value]) => {
|
||||
networkconfiguration[key] = value ?? null;
|
||||
});
|
||||
data.network = networkconfiguration;
|
||||
}
|
||||
if (this.addon.network_isolation_available) {
|
||||
data.network_isolation = isolation;
|
||||
}
|
||||
const data: HassioAddonSetOptionParams = {
|
||||
network: networkconfiguration,
|
||||
};
|
||||
|
||||
try {
|
||||
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
|
||||
@@ -462,36 +254,6 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
|
||||
.show-optional {
|
||||
padding: 16px;
|
||||
}
|
||||
ha-form + .isolation {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
.isolation .secondary {
|
||||
margin-top: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.isolation ha-select,
|
||||
.isolation ha-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.isolation ha-input {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.isolation .mac {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.isolation .mac code {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
.isolation ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.isolation ha-alert ul {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -832,7 +832,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
</span>
|
||||
<code slot="headline"> ${this._currentAddon.hostname} </code>
|
||||
</ha-row-item>
|
||||
${this._renderNetworkIsolationRows()}
|
||||
${metrics.map(
|
||||
(metric) => html`
|
||||
<supervisor-app-metric
|
||||
@@ -843,46 +842,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
`
|
||||
)}`
|
||||
: nothing}
|
||||
${this._currentAddon.version &&
|
||||
this._currentAddon.state !== "started" &&
|
||||
this._currentAddon.network_isolation
|
||||
? html`<wa-divider></wa-divider>
|
||||
${this._renderNetworkIsolationRows()}`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNetworkIsolationRows() {
|
||||
const addon = this._currentAddon;
|
||||
if (!addon.version || !addon.network_isolation) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-row-item>
|
||||
<span slot="supporting-text">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.network_isolation_ip"
|
||||
)}
|
||||
</span>
|
||||
<code slot="headline"> ${addon.network_isolation.ipv4} </code>
|
||||
</ha-row-item>
|
||||
${addon.network_isolation_mac
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<span slot="supporting-text">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.network_isolation_mac"
|
||||
)}
|
||||
</span>
|
||||
<code slot="headline"> ${addon.network_isolation_mac} </code>
|
||||
</ha-row-item>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${"protected" in this._currentAddon && !this._currentAddon.protected
|
||||
|
||||
@@ -261,15 +261,22 @@ 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 {
|
||||
padding: var(--ha-space-3) var(--ha-space-2);
|
||||
background: var(--sidebar-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -288,29 +288,28 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
Object.values(this.hass.devices),
|
||||
this._entityReg
|
||||
);
|
||||
const { devices, entities } = memberships;
|
||||
const quickLinkCounts = this._getQuickLinkCounts(
|
||||
memberships,
|
||||
this._related
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Group entities by domain
|
||||
const groupedEntities = groupBy(entities, (entity) =>
|
||||
|
||||
@@ -18,13 +18,22 @@ 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 LitElement implements HassDialog {
|
||||
class DialogAutomationMode
|
||||
extends DirtyStateProviderMixin<AutomationModeState>()(LitElement)
|
||||
implements HassDialog
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -42,6 +51,10 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
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 {
|
||||
@@ -70,6 +83,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -123,7 +137,11 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.change_mode"
|
||||
)}
|
||||
@@ -141,6 +159,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
} else if (!this._newMax) {
|
||||
this._newMax = AUTOMATION_DEFAULT_MAX;
|
||||
}
|
||||
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
|
||||
}
|
||||
|
||||
private _valueChanged(ev: InputEvent) {
|
||||
@@ -148,6 +167,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
const target = ev.target as HaInput;
|
||||
if (target.name === "max") {
|
||||
this._newMax = Number(target.value);
|
||||
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 {
|
||||
@@ -38,8 +39,18 @@ 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 LitElement implements HassDialog {
|
||||
class DialogAutomationSave
|
||||
extends DirtyStateProviderMixin<AutomationSaveState>()(LitElement)
|
||||
implements HassDialog
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -81,6 +92,16 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
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 {
|
||||
@@ -252,6 +273,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
.open=${this._open}
|
||||
@closed=${this._dialogClosed}
|
||||
header-title=${this._params.title || title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._params.hideInputs
|
||||
? nothing
|
||||
@@ -281,7 +303,11 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!!this._params.config.alias && !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize(
|
||||
this._params.config.alias && !this._params.onDiscard
|
||||
? "ui.panel.config.automation.editor.rename"
|
||||
@@ -299,17 +325,28 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
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) {
|
||||
@@ -320,6 +357,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
} else {
|
||||
this._newName = target.value;
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private _handleDiscard() {
|
||||
@@ -387,6 +425,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
this._visibleOptionals = [...this._visibleOptionals, "labels"];
|
||||
}
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
|
||||
@@ -164,6 +164,9 @@ 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,6 +251,13 @@ 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) =>
|
||||
@@ -258,13 +265,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
)
|
||||
: automations
|
||||
).map((automation) => {
|
||||
const entityRegEntry = entityReg.find(
|
||||
(reg) => reg.entity_id === automation.entity_id
|
||||
);
|
||||
const entityRegEntry = entityRegLookup.get(automation.entity_id);
|
||||
const category = entityRegEntry?.categories.automation;
|
||||
const labels = labelReg && entityRegEntry?.labels;
|
||||
const label_entries = (labels || [])
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
|
||||
.map((lbl) => labelLookup!.get(lbl)!)
|
||||
.filter(Boolean);
|
||||
const assistants = getEntityVoiceAssistantsIds(
|
||||
entityReg,
|
||||
|
||||
@@ -55,6 +55,9 @@ 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>
|
||||
`
|
||||
@@ -78,6 +81,9 @@ 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,6 +201,9 @@ 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}
|
||||
|
||||
@@ -84,7 +84,10 @@ export class CloudRegister extends LitElement {
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3"
|
||||
)}
|
||||
<a href="https://www.nabucasa.com" target="_blank"
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Nabu Casa, Inc</a
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -37,6 +37,12 @@ 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>;
|
||||
@@ -57,11 +63,15 @@ 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"
|
||||
@@ -81,14 +91,14 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header" role="columnheader">
|
||||
<div class="header" role="columnheader" ?hidden=${!showDevice}>
|
||||
<span class="padded">
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.entities.picker.headers.device"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header" role="columnheader">
|
||||
<div class="header" role="columnheader" ?hidden=${!showArea}>
|
||||
<span class="padded">
|
||||
${this._i18n.localize("ui.panel.config.generic.headers.area")}
|
||||
</span>
|
||||
@@ -355,6 +365,24 @@ 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,6 +87,18 @@ 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()
|
||||
@@ -157,14 +169,32 @@ class HaPanelDevState extends LitElement {
|
||||
)}
|
||||
</h1>
|
||||
${!this.narrow
|
||||
? html`<ha-checkbox
|
||||
.checked=${this._showAttributes}
|
||||
@change=${this._saveAttributeCheckboxState}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.developer-tools.tabs.states.attributes"
|
||||
)}
|
||||
</ha-checkbox>`
|
||||
? 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>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-expansion-panel
|
||||
@@ -280,6 +310,8 @@ 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
|
||||
@@ -593,6 +625,14 @@ 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;
|
||||
@@ -617,12 +657,25 @@ class HaPanelDevState extends LitElement {
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.heading ha-checkbox {
|
||||
margin-right: var(--ha-space-2);
|
||||
justify-content: center;
|
||||
.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;
|
||||
}
|
||||
|
||||
.entity-id {
|
||||
|
||||
@@ -452,6 +452,12 @@ 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
|
||||
@@ -462,7 +468,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
const labels = labelReg && device?.labels;
|
||||
const labelsEntries = (labels || [])
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
|
||||
.map((lbl) => labelLookup!.get(lbl))
|
||||
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
|
||||
|
||||
const { areaName } = computeDeviceAreaLabel(
|
||||
|
||||
@@ -894,11 +894,17 @@ 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,9 +1178,17 @@ 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 (
|
||||
(changedProps.has("hass") &&
|
||||
(!oldHass || oldHass.states !== this.hass.states)) ||
|
||||
stateEntityAdded ||
|
||||
changedProps.has("_entities") ||
|
||||
changedProps.has("_entitySources") ||
|
||||
changedProps.has("_exposedEntities")
|
||||
|
||||
@@ -1259,11 +1259,14 @@ ${rejected
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = Object.keys(this._entitySource);
|
||||
// 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 newHelpers = Object.values(this.hass!.states).filter(
|
||||
(entity) =>
|
||||
entityIds.includes(entity.entity_id) ||
|
||||
entityIds.has(entity.entity_id) ||
|
||||
isHelperDomain(computeStateDomain(entity))
|
||||
);
|
||||
|
||||
|
||||
@@ -13,12 +13,10 @@ 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-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import type { HassioHassOSInfo } from "../../../data/hassio/host";
|
||||
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
|
||||
import type { HassioInfo } from "../../../data/hassio/supervisor";
|
||||
@@ -200,8 +198,8 @@ class HaConfigInfo extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-card outlined class="pages">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item type="button" @click=${this._showShortcuts}>
|
||||
<ha-list-base>
|
||||
<ha-list-item-button @click=${this._showShortcuts}>
|
||||
<div
|
||||
slot="start"
|
||||
class="icon-background"
|
||||
@@ -209,15 +207,14 @@ class HaConfigInfo extends LitElement {
|
||||
>
|
||||
<ha-svg-icon .path=${mdiKeyboard}></ha-svg-icon>
|
||||
</div>
|
||||
<span
|
||||
<span slot="headline"
|
||||
>${this.hass.localize("ui.panel.config.info.shortcuts")}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
|
||||
${PAGES.map(
|
||||
(page) => html`
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
<ha-list-item-button
|
||||
.href=${documentationUrl(this.hass, page.path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -225,20 +222,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>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.info.items.${page.name}`
|
||||
)}
|
||||
</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</ha-list-base>
|
||||
${customUiList.length
|
||||
? html`
|
||||
<div class="custom-ui">
|
||||
@@ -246,8 +243,9 @@ class HaConfigInfo extends LitElement {
|
||||
${customUiList.map(
|
||||
(item) => html`
|
||||
<div>
|
||||
<a href=${item.url} target="_blank"> ${item.name}</a>:
|
||||
${item.version}
|
||||
<a href=${item.url} target="_blank" rel="noreferrer">
|
||||
${item.name}</a
|
||||
>: ${item.version}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
@@ -391,12 +389,15 @@ 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,6 +354,7 @@ export class HaConfigEntryRow extends LitElement {
|
||||
<a
|
||||
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click=${this._signUrl}
|
||||
>
|
||||
<ha-dropdown-item value="diagnostics">
|
||||
|
||||
@@ -27,7 +27,6 @@ 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";
|
||||
@@ -163,8 +162,6 @@ 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;
|
||||
@@ -386,16 +383,6 @@ 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>) {
|
||||
@@ -650,9 +637,6 @@ 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,9 +38,6 @@ 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[] = [];
|
||||
|
||||
@@ -92,6 +92,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
|
||||
this._configEntryId || ""
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click=${this._signUrl}
|
||||
>
|
||||
<ha-dropdown-item>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 {
|
||||
@@ -42,8 +43,16 @@ const SUGGESTION_INCLUDE: MetadataSuggestionInclude = {
|
||||
labels: true,
|
||||
};
|
||||
|
||||
interface SceneSaveState {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
entryUpdates: EntityRegistryUpdate;
|
||||
}
|
||||
|
||||
@customElement("ha-dialog-scene-save")
|
||||
class DialogSceneSave extends LitElement {
|
||||
class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _open = false;
|
||||
@@ -80,6 +89,15 @@ class DialogSceneSave extends LitElement {
|
||||
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() {
|
||||
@@ -188,6 +206,7 @@ class DialogSceneSave extends LitElement {
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title || title}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._params.hideInputs
|
||||
@@ -230,7 +249,11 @@ class DialogSceneSave extends LitElement {
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!!this._params.config.id && !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize(
|
||||
this._params.config.id && !this._params.onDiscard
|
||||
? "ui.panel.config.scene.editor.rename"
|
||||
@@ -248,17 +271,27 @@ class DialogSceneSave extends LitElement {
|
||||
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) {
|
||||
@@ -267,6 +300,7 @@ class DialogSceneSave extends LitElement {
|
||||
if (this._error && this._newName.trim()) {
|
||||
this._error = false;
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private _handleDiscard() {
|
||||
@@ -325,6 +359,7 @@ class DialogSceneSave extends LitElement {
|
||||
this._visibleOptionals = [...this._visibleOptionals, "labels"];
|
||||
}
|
||||
}
|
||||
this._trackDirtyState();
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -37,7 +38,7 @@ const WELCOME_SCHEMA: HaFormSchema[] = [
|
||||
|
||||
@customElement("dialog-edit-home")
|
||||
export class DialogEditHome
|
||||
extends LitElement
|
||||
extends DirtyStateProviderMixin<EditorState>()(LitElement)
|
||||
implements HassDialog<EditHomeDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,6 +61,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ export class DialogEditHome
|
||||
.headerSubtitle=${this.hass.localize(
|
||||
"ui.panel.home.editor.description"
|
||||
)}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-alert alert-type="info">
|
||||
@@ -178,7 +180,7 @@ export class DialogEditHome
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
@@ -222,6 +224,7 @@ export class DialogEditHome
|
||||
...this._state!,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _welcomeChanged(
|
||||
@@ -231,6 +234,7 @@ export class DialogEditHome
|
||||
...this._state!,
|
||||
show_welcome_message: ev.detail.value.show_welcome_message,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _suggestedChanged(
|
||||
@@ -240,6 +244,7 @@ export class DialogEditHome
|
||||
...this._state!,
|
||||
show_suggested_entities: ev.detail.value.show_suggested_entities,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _shortcutsChanged(ev: ValueChangedEvent<ShortcutItem[]>): void {
|
||||
@@ -247,6 +252,7 @@ export class DialogEditHome
|
||||
...this._state!,
|
||||
shortcuts: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
@@ -270,6 +276,7 @@ export class DialogEditHome
|
||||
|
||||
try {
|
||||
await this._params.saveConfig(config);
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
|
||||
@@ -10,13 +10,14 @@ 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 LitElement
|
||||
extends DirtyStateProviderMixin<CustomShortcutItem>()(LitElement)
|
||||
implements HassDialog<EditShortcutDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -32,6 +33,7 @@ export class DialogEditShortcut
|
||||
public showDialog(params: EditShortcutDialogParams): void {
|
||||
this._params = params;
|
||||
this._data = { ...params.item };
|
||||
this._initDirtyTracking({ type: "shallow" }, this._data);
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ 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
|
||||
@@ -107,7 +110,11 @@ export class DialogEditShortcut
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._save}>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${!this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
@@ -124,6 +131,7 @@ export class DialogEditShortcut
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this._data = ev.detail.value as CustomShortcutItem;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
private _save() {
|
||||
@@ -136,6 +144,7 @@ export class DialogEditShortcut
|
||||
icon: icon || undefined,
|
||||
color: color || undefined,
|
||||
});
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
|
||||
@@ -267,8 +267,6 @@ 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;
|
||||
@@ -280,14 +278,12 @@ function formatTooltip(
|
||||
if (value === "0") {
|
||||
continue;
|
||||
}
|
||||
if (param.componentSubType === "bar") {
|
||||
if (y > 0) {
|
||||
sumPositive += y;
|
||||
countPositive++;
|
||||
} else {
|
||||
sumNegative += y;
|
||||
countNegative++;
|
||||
}
|
||||
// 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++;
|
||||
}
|
||||
rows.push(
|
||||
html`<ha-chart-tooltip-marker
|
||||
@@ -305,8 +301,6 @@ 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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -568,8 +568,11 @@ 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) =>
|
||||
chartDataCompare.find((d2) => (d2 as any).id === d.id)
|
||||
compareById.get(d.id)
|
||||
) as typeof chartDataCompare;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ 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)
|
||||
@@ -174,15 +181,10 @@ export class HuiEnergyUsageGraphCard
|
||||
}
|
||||
|
||||
private _formatTotal = (total: number) =>
|
||||
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) }
|
||||
);
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
|
||||
{ num: formatNumber(total, this.hass.locale) }
|
||||
);
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(
|
||||
@@ -559,7 +561,7 @@ export class HuiEnergyUsageGraphCard
|
||||
this._compareStart!
|
||||
);
|
||||
|
||||
Object.entries(combinedData).forEach(([type, sources], idx) => {
|
||||
Object.entries(combinedData).forEach(([type, sources]) => {
|
||||
Object.entries(sources).forEach(([statId, source]) => {
|
||||
const points: BarSeriesOption["data"] = [];
|
||||
// Process chart data.
|
||||
@@ -592,12 +594,7 @@ export class HuiEnergyUsageGraphCard
|
||||
statisticsMetaData[statId]
|
||||
),
|
||||
// @ts-expect-error
|
||||
order:
|
||||
type === "used_solar"
|
||||
? 1
|
||||
: type === "to_battery"
|
||||
? Object.keys(combinedData).length
|
||||
: idx + 2,
|
||||
order: stackOrder[type] ?? Object.keys(combinedData).length,
|
||||
barMaxWidth: 50,
|
||||
itemStyle: {
|
||||
borderColor: getEnergyColor(
|
||||
|
||||
@@ -236,7 +236,17 @@ export class HuiPowerSourcesGraphCard
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
Object.keys(statIds).forEach((key, keyIndex) => {
|
||||
const seriesData: Record<
|
||||
string,
|
||||
{
|
||||
colorHex: string;
|
||||
rgb: [number, number, number];
|
||||
positive: [number, number][];
|
||||
negative: [number, number][];
|
||||
}
|
||||
> = {};
|
||||
|
||||
Object.keys(statIds).forEach((key) => {
|
||||
if (statIds[key].stats.length) {
|
||||
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
|
||||
const rgb = hex2rgb(colorHex);
|
||||
@@ -261,14 +271,32 @@ export class HuiPowerSourcesGraphCard
|
||||
}),
|
||||
trackY
|
||||
);
|
||||
datasets.push({
|
||||
...commonSeriesOptions,
|
||||
id: key,
|
||||
name: statIds[key].name,
|
||||
color: colorHex,
|
||||
stack: "positive",
|
||||
areaStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
|
||||
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,
|
||||
[
|
||||
{
|
||||
offset: 0,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||
@@ -277,34 +305,32 @@ export class HuiPowerSourcesGraphCard
|
||||
offset: 1,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
|
||||
},
|
||||
]),
|
||||
},
|
||||
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
|
||||
});
|
||||
}
|
||||
]
|
||||
),
|
||||
},
|
||||
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];
|
||||
|
||||
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: 16px 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.deleteItemButton {
|
||||
|
||||
@@ -14,13 +14,16 @@ import type {
|
||||
DataEntryFlowStep,
|
||||
DataEntryFlowStepForm,
|
||||
} from "../../data/data_entry_flow";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
let instance = 0;
|
||||
|
||||
@customElement("ha-mfa-module-setup-flow")
|
||||
class HaMfaModuleSetupFlow extends LitElement {
|
||||
class HaMfaModuleSetupFlow extends DirtyStateProviderMixin<
|
||||
Record<string, unknown>
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _dialogClosedCallback?: (params: {
|
||||
@@ -83,7 +86,7 @@ class HaMfaModuleSetupFlow extends LitElement {
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
header-title=${this._computeStepTitle()}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
@@ -220,6 +223,7 @@ class HaMfaModuleSetupFlow extends LitElement {
|
||||
|
||||
private _stepDataChanged(ev: CustomEvent) {
|
||||
this._stepData = ev.detail.value;
|
||||
this._updateDirtyState(this._stepData);
|
||||
}
|
||||
|
||||
private _submitStep() {
|
||||
@@ -317,6 +321,7 @@ class HaMfaModuleSetupFlow extends LitElement {
|
||||
// We got a new form if there are no errors.
|
||||
if (Object.keys(step.errors).length === 0) {
|
||||
this._stepData = {};
|
||||
this._initDirtyTracking({ type: "shallow" }, {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/input/ha-input";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-token-dialog";
|
||||
@@ -18,7 +19,9 @@ import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-
|
||||
const QR_LOGO_URL = "/static/icons/favicon-192x192.png";
|
||||
|
||||
@customElement("ha-long-lived-access-token-dialog")
|
||||
export class HaLongLivedAccessTokenDialog extends LitElement {
|
||||
export class HaLongLivedAccessTokenDialog extends DirtyStateProviderMixin<string>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _qrCode?: TemplateResult;
|
||||
@@ -46,6 +49,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
|
||||
);
|
||||
this._renderDialog = true;
|
||||
this._open = true;
|
||||
this._initDirtyTracking({ type: "shallow" }, "");
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -80,7 +84,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
|
||||
: this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.create"
|
||||
)}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="content">
|
||||
@@ -177,10 +181,16 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
|
||||
private _nameChanged(ev: Event) {
|
||||
this._name = (ev.currentTarget as HTMLInputElement).value;
|
||||
this._errorMessage = undefined;
|
||||
this._updateDirtyState(this._name);
|
||||
}
|
||||
|
||||
private _isCreateDisabled() {
|
||||
return this._loading || !this._name.trim() || this._hasDuplicateName();
|
||||
return (
|
||||
this._loading ||
|
||||
!this._name.trim() ||
|
||||
this._hasDuplicateName() ||
|
||||
!this.isDirtyState
|
||||
);
|
||||
}
|
||||
|
||||
private async _createToken(): Promise<void> {
|
||||
@@ -200,6 +210,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
|
||||
client_name: name,
|
||||
});
|
||||
this._name = name;
|
||||
this._markDirtyStateClean();
|
||||
this._createdCallback();
|
||||
} catch (err: unknown) {
|
||||
this._errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -2,11 +2,12 @@ export function computeCssVariable(
|
||||
props: string | string[]
|
||||
): string | undefined {
|
||||
if (Array.isArray(props)) {
|
||||
return props
|
||||
.reverse()
|
||||
.reduce<
|
||||
string | undefined
|
||||
>((str, variable) => `var(${variable}${str ? `, ${str}` : ""})`, undefined);
|
||||
// reduceRight builds the nested var() fallback chain from last to first
|
||||
// without mutating the caller's array (unlike reverse()).
|
||||
return props.reduceRight<string | undefined>(
|
||||
(str, variable) => `var(${variable}${str ? `, ${str}` : ""})`,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return `var(${props})`;
|
||||
}
|
||||
|
||||
@@ -60,16 +60,24 @@ export const createLogMessage = async (
|
||||
// - a possible list of aggregated errors
|
||||
if (error instanceof Error) {
|
||||
lines.push(error.toString() || messageFallback);
|
||||
const stackLines = (await fromError(error))
|
||||
.slice(0, MAX_STACK_FRAMES)
|
||||
.map((frame) => {
|
||||
frame.fileName ??= "";
|
||||
if (URL.canParse(frame.fileName)) {
|
||||
frame.fileName = new URL(frame.fileName).pathname;
|
||||
}
|
||||
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
|
||||
return frame.toString();
|
||||
});
|
||||
let stackLines: (string | undefined)[];
|
||||
try {
|
||||
stackLines = (await fromError(error))
|
||||
.slice(0, MAX_STACK_FRAMES)
|
||||
.map((frame) => {
|
||||
frame.fileName ??= "";
|
||||
if (URL.canParse(frame.fileName)) {
|
||||
frame.fileName = new URL(frame.fileName).pathname;
|
||||
}
|
||||
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
|
||||
return frame.toString();
|
||||
});
|
||||
} catch {
|
||||
// stacktrace-js cannot always parse a stack (for example a DOMException
|
||||
// with no, or an unrecognized, stack), so fall back to the raw stack
|
||||
// instead of letting the error logger itself throw.
|
||||
stackLines = error.stack ? [error.stack] : [];
|
||||
}
|
||||
lines.push(...(stackLines.length > 0 ? stackLines : [stackFallback]));
|
||||
// @ts-expect-error Requires library bump to ES2022
|
||||
if (error.cause) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const mdiMatterLogo =
|
||||
"M21.1688 13.7795V10.8875C18.4835 11.6576 16.239 13.3844 14.8539 15.777C13.4486 18.1276 13.0706 20.9372 13.7842 23.6005L16.3017 22.1646C16.1338 20.8952 16.2186 19.6055 16.6578 18.3781L22.5742 21.7493L24 20.9792V19.397L18.0412 16.0057C18.8803 14.9867 19.972 14.2571 21.1672 13.7795M2.8108 10.8859V13.7779C4.0483 14.2571 5.0977 14.9851 5.9368 16.0041L0 19.397V20.9792L1.4477 21.7493L7.3438 18.3781C7.7626 19.6055 7.8473 20.8952 7.6795 22.1646L10.197 23.6005C10.8886 20.9372 10.5122 18.1276 9.1695 15.777C7.7438 13.3843 5.4976 11.6575 2.8124 10.8875M16.8885 6.4119C15.8391 7.182 14.7067 7.7436 13.3844 7.9722V1.2315L12.0418 0.3992L10.6144 1.2315V7.9738C9.3345 7.7451 8.1393 7.1835 7.1323 6.4135L4.6148 7.8494C6.6712 9.7644 9.2718 10.8456 12.0418 10.8456C14.8118 10.8456 17.4124 9.7629 19.4059 7.8494L16.8885 6.4135V6.4119Z";
|
||||
+15
-32
@@ -600,7 +600,7 @@
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"yaml_warning": "It appears you may be writing YAML into this template field (saw ''{string}''), which is likely incorrect. This field is intended for templates only (e.g. '{{ states(sensor.test) > 0 }}' ).",
|
||||
"yaml_warning": "It appears you may be writing YAML into this template field (saw ''{string}''), which is likely incorrect. This field is intended for templates only (for example, '{{ states(sensor.test) > 0 }}').",
|
||||
"learn_more": "Learn more about templating"
|
||||
},
|
||||
"text": {
|
||||
@@ -1892,12 +1892,14 @@
|
||||
"editor": {
|
||||
"name": "Name",
|
||||
"icon": "Icon",
|
||||
"icon_error": "Icons should be in the format 'prefix:iconname', e.g. 'mdi:home'",
|
||||
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
|
||||
"default_code": "Default code",
|
||||
"default_code_error": "Code does not match code format",
|
||||
"calendar_color": "Calendar color",
|
||||
"associated_zone": "Associated zone",
|
||||
"entity_id": "Entity ID",
|
||||
"copy_entity_id": "Copy entity ID",
|
||||
"restore_entity_id": "Restore entity ID",
|
||||
"unit_of_measurement": "Unit of measurement",
|
||||
"precipitation_unit": "Precipitation unit",
|
||||
"precision": "Display precision",
|
||||
@@ -2904,8 +2906,6 @@
|
||||
"current_version": "Current version: {version}",
|
||||
"changelog": "Changelog",
|
||||
"hostname": "Hostname",
|
||||
"network_isolation_ip": "IP address on your network",
|
||||
"network_isolation_mac": "MAC address on your network",
|
||||
"visit_app_page": "Visit {name} page for more details.",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
@@ -3080,23 +3080,7 @@
|
||||
"header": "Network",
|
||||
"introduction": "Configure the network ports that this app uses.",
|
||||
"show_disabled": "Show disabled ports",
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"isolation": {
|
||||
"title": "Isolated network access",
|
||||
"description": "Run this app with its own IP address on a selected network instead of sharing the host network. Ingress and the web UI link keep working as before.",
|
||||
"interface": "Network interface",
|
||||
"ip_address": "IP address",
|
||||
"ip_address_helper": "Choose an address outside the DHCP range of your router, or reserve it in your router.",
|
||||
"mac_address": "MAC address of this app on your network",
|
||||
"no_interface": "Select a network interface to use isolated network access.",
|
||||
"invalid_ip": "Enter a valid IPv4 address, like 192.168.1.50.",
|
||||
"info": {
|
||||
"separate_device": "The app joins the selected network as a separate device with its own IP and MAC address.",
|
||||
"ipv6": "IPv6 needs no setup: the app automatically gets its IPv6 addresses from your network.",
|
||||
"host_reachability": "Your Home Assistant system and the app cannot reach each other through their addresses on the selected network. This is part of the isolation — ingress, the web UI link, and other Home Assistant features are not affected.",
|
||||
"host_interfaces": "Apps that need access to all network interfaces of your system may not work as expected with isolated network access."
|
||||
}
|
||||
}
|
||||
"reset_defaults": "Reset to defaults"
|
||||
},
|
||||
"audio": {
|
||||
"header": "Audio",
|
||||
@@ -4298,7 +4282,7 @@
|
||||
"cost_stat_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_stat_input%]",
|
||||
"cost_entity": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity%]",
|
||||
"cost_entity_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity_input%]",
|
||||
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid {class} unit)` (e.g. `{currency}/{unit1}` or `{currency}/{unit2}`) may be used and will be automatically converted.",
|
||||
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid {class} unit)` (like `{currency}/{unit1}` or `{currency}/{unit2}`) may be used and will be automatically converted.",
|
||||
"cost_entity_helper_energy": "energy",
|
||||
"cost_entity_helper_volume": "volume",
|
||||
"cost_number": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
|
||||
@@ -4327,7 +4311,7 @@
|
||||
"cost_stat_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_stat_input%]",
|
||||
"cost_entity": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity%]",
|
||||
"cost_entity_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity_input%]",
|
||||
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid water unit)` (e.g. `{currency}/gal` or `{currency}/m³`) may be used and will be automatically converted.",
|
||||
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid water unit)` (like `{currency}/gal` or `{currency}/m³`) may be used and will be automatically converted.",
|
||||
"cost_number": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
|
||||
"cost_number_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
|
||||
"water_usage": "Water consumption",
|
||||
@@ -4457,7 +4441,7 @@
|
||||
},
|
||||
"url": {
|
||||
"caption": "Home Assistant URL",
|
||||
"description": "Configure what website addresses Home Assistant should share with other devices when they need to fetch data from Home Assistant (e.g. to play text-to-speech or other hosted media).",
|
||||
"description": "Configure what website addresses Home Assistant should share with other devices when they need to fetch data from Home Assistant (for example, to play text-to-speech or other hosted media).",
|
||||
"internal_url_label": "Local network",
|
||||
"external_url_label": "Internet",
|
||||
"external_use_ha_cloud": "Use Home Assistant Cloud",
|
||||
@@ -5345,9 +5329,9 @@
|
||||
"type_input": "Numeric value of another entity",
|
||||
"description": {
|
||||
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
|
||||
}
|
||||
},
|
||||
"persistent_notification": {
|
||||
@@ -5659,7 +5643,7 @@
|
||||
"zone": "[%key:ui::panel::config::automation::editor::triggers::type::zone::label%]",
|
||||
"description": {
|
||||
"picker": "Tests if someone (or something) is in a zone.",
|
||||
"full": "If {entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} in {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
|
||||
"full": "If {entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} in {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8751,7 +8735,6 @@
|
||||
"no_data_period": "There is no data for this period.",
|
||||
"energy_usage_graph": {
|
||||
"total_consumed": "Total consumed {num} kWh",
|
||||
"total_returned": "Total exported {num} kWh",
|
||||
"total_usage": "+{num} kWh",
|
||||
"combined_from_grid": "Combined from grid",
|
||||
"consumed_solar": "Consumed solar",
|
||||
@@ -9069,7 +9052,7 @@
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
},
|
||||
"badges_wrap": "Badges behaviour",
|
||||
"badges_wrap": "Badges behavior",
|
||||
"badges_wrap_options": {
|
||||
"wrap": "Wrap",
|
||||
"scroll": "Scroll",
|
||||
@@ -9214,7 +9197,7 @@
|
||||
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
|
||||
"settings": {
|
||||
"column_span": "Width",
|
||||
"column_span_helper": "Larger sections will be made smaller to fit the display. (e.g. on mobile devices)",
|
||||
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
|
||||
"background": "Background options",
|
||||
"background_enabled": "Background",
|
||||
"background_enabled_helper": "Display a colored background behind the section",
|
||||
@@ -10027,7 +10010,7 @@
|
||||
"name": "Tile",
|
||||
"description": "This card gives you a quick overview of an entity. It allows you to toggle the entity, show the More info dialog or trigger custom actions.",
|
||||
"color": "Color",
|
||||
"color_helper": "Inactive state (e.g. off, closed) will not be colored.",
|
||||
"color_helper": "Inactive state (for example, off or closed) will not be colored.",
|
||||
"icon_tap_action": "Icon tap behavior",
|
||||
"icon_hold_action": "Icon hold behavior",
|
||||
"icon_double_tap_action": "Icon double tap behavior",
|
||||
|
||||
@@ -64,6 +64,26 @@ describe("formatNumber", () => {
|
||||
assert.strictEqual(formatNumber("", defaultLocale), "0");
|
||||
});
|
||||
|
||||
it("Returns consistent results for interleaved calls with different options (formatter cache)", () => {
|
||||
// Exercises the cached Intl.NumberFormat instances: alternating option
|
||||
// shapes must each keep producing their own correct output.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.strictEqual(formatNumber(1234.5, defaultLocale), "1,234.5");
|
||||
assert.strictEqual(
|
||||
formatNumber(1234.5, defaultLocale, { minimumFractionDigits: 2 }),
|
||||
"1,234.50"
|
||||
);
|
||||
assert.strictEqual(formatNumber("1234.50", defaultLocale), "1,234.50");
|
||||
assert.strictEqual(
|
||||
formatNumber(1234.5, {
|
||||
...defaultLocale,
|
||||
number_format: NumberFormat.none,
|
||||
}),
|
||||
"1234.5"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("Formats number with options", () => {
|
||||
assert.strictEqual(
|
||||
formatNumber(1234.5, defaultLocale, {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { isTimestamp } from "../../../src/common/string/is_timestamp";
|
||||
|
||||
test("isTimestamp accepts valid timestamps", () => {
|
||||
expect(isTimestamp("2021-06-15T08:30:00Z")).toBe(true);
|
||||
expect(isTimestamp("2021-06-15 08:30:00")).toBe(true);
|
||||
expect(isTimestamp("2021-06-15T08:30")).toBe(true);
|
||||
expect(isTimestamp("2021-12-31T23:59:59")).toBe(true);
|
||||
expect(isTimestamp("2021-06-15T08:30:00.123+02:00")).toBe(true);
|
||||
expect(isTimestamp("2021-06-15T24:00")).toBe(true);
|
||||
});
|
||||
|
||||
test("isTimestamp rejects non-timestamps", () => {
|
||||
expect(isTimestamp("not a date")).toBe(false);
|
||||
expect(isTimestamp("2021/06/15T08:30")).toBe(false);
|
||||
expect(isTimestamp("2021-13-01T00:00")).toBe(false);
|
||||
expect(isTimestamp("2021-00-01T00:00")).toBe(false);
|
||||
expect(isTimestamp("2021-06-32T00:00")).toBe(false);
|
||||
expect(isTimestamp("2021-06-15T25:00")).toBe(false);
|
||||
});
|
||||
|
||||
test("isTimestamp does not allow a leading plus or minus", () => {
|
||||
expect(isTimestamp("+2021-06-15T08:30")).toBe(false);
|
||||
expect(isTimestamp("-2021-06-15T08:30")).toBe(false);
|
||||
});
|
||||
|
||||
test("isTimestamp requires a time component after the date", () => {
|
||||
expect(isTimestamp("2021-06-15")).toBe(false);
|
||||
});
|
||||
|
||||
test("isTimestamp rejects week-number dates", () => {
|
||||
expect(isTimestamp("2021-W24-2T08:30")).toBe(false);
|
||||
});
|
||||
|
||||
test("isTimestamp rejects a year on its own", () => {
|
||||
expect(isTimestamp("2021")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyToClipboard } from "../../../src/common/util/copy-clipboard";
|
||||
|
||||
const deepActiveElement = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../src/common/dom/deep-active-element", () => ({
|
||||
deepActiveElement,
|
||||
}));
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const hadExecCommand = "execCommand" in document;
|
||||
|
||||
const setClipboard = (value: unknown) => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// jsdom does not implement execCommand, so provide a stub for the fallback.
|
||||
const stubExecCommand = () => {
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
(document as any).execCommand = execCommand;
|
||||
return execCommand;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
deepActiveElement.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setClipboard(originalClipboard);
|
||||
if (!hadExecCommand) {
|
||||
delete (document as any).execCommand;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses the async clipboard API when available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setClipboard({ writeText });
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
// The fallback should not run when the async API succeeds.
|
||||
expect(deepActiveElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back without throwing when the active element is in the light DOM", async () => {
|
||||
setClipboard(undefined);
|
||||
const execCommand = stubExecCommand();
|
||||
// An element in the main document: getRootNode() returns the document,
|
||||
// which cannot have a textarea appended to it directly.
|
||||
const lightEl = document.createElement("div");
|
||||
document.body.appendChild(lightEl);
|
||||
deepActiveElement.mockReturnValue(lightEl);
|
||||
|
||||
const appendSpy = vi.spyOn(document.body, "appendChild");
|
||||
|
||||
await expect(copyToClipboard("hello")).resolves.toBeUndefined();
|
||||
|
||||
expect(appendSpy).toHaveBeenCalled();
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
// The temporary textarea is cleaned up.
|
||||
expect(document.body.querySelector("textarea")).toBeNull();
|
||||
|
||||
document.body.removeChild(lightEl);
|
||||
});
|
||||
|
||||
it("appends into the shadow root when the active element lives in one", async () => {
|
||||
setClipboard(undefined);
|
||||
stubExecCommand();
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const shadowEl = document.createElement("span");
|
||||
shadow.appendChild(shadowEl);
|
||||
deepActiveElement.mockReturnValue(shadowEl);
|
||||
|
||||
const shadowAppend = vi.spyOn(shadow, "appendChild");
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(shadowAppend).toHaveBeenCalled();
|
||||
// The temporary textarea is cleaned up.
|
||||
expect(shadow.querySelector("textarea")).toBeNull();
|
||||
|
||||
document.body.removeChild(host);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchSelectOptionValue } from "../../../src/components/ha-form/ha-form-select";
|
||||
import type { HaFormSelectSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
// The backend can send non-string option values (e.g. integers from a vol.In
|
||||
// schema) even though the type declares strings, so cast in the test.
|
||||
const asOptions = (options: (readonly [unknown, string])[]) =>
|
||||
options as unknown as HaFormSelectSchema["options"];
|
||||
|
||||
describe("matchSelectOptionValue", () => {
|
||||
it("retains the numeric type of a matched option", () => {
|
||||
const options = asOptions([
|
||||
[1, "One"],
|
||||
[5, "Five"],
|
||||
[6, "Six"],
|
||||
]);
|
||||
expect(matchSelectOptionValue(options, "5")).toBe(5);
|
||||
});
|
||||
|
||||
it("matches a zero value correctly", () => {
|
||||
const options = asOptions([
|
||||
[0, "Zero"],
|
||||
[1, "One"],
|
||||
]);
|
||||
expect(matchSelectOptionValue(options, "0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns string option values unchanged", () => {
|
||||
const options = asOptions([
|
||||
["a", "A"],
|
||||
["b", "B"],
|
||||
]);
|
||||
expect(matchSelectOptionValue(options, "b")).toBe("b");
|
||||
});
|
||||
|
||||
it("returns the value unchanged when no option matches", () => {
|
||||
const options = asOptions([["a", "A"]]);
|
||||
expect(matchSelectOptionValue(options, "missing")).toBe("missing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEntities } from "../../../src/data/entity/entity_picker";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const makeHass = (entityIds: string[]): HomeAssistant => {
|
||||
const states: Record<string, any> = {};
|
||||
for (const id of entityIds) {
|
||||
states[id] = {
|
||||
entity_id: id,
|
||||
state: "on",
|
||||
attributes: { friendly_name: id },
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
};
|
||||
}
|
||||
return {
|
||||
states,
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
language: "en",
|
||||
localize: ((key: string) => key) as any,
|
||||
translationMetadata: { translations: {} },
|
||||
} as unknown as HomeAssistant;
|
||||
};
|
||||
|
||||
const ids = (items: { id: string }[]) => items.map((item) => item.id).sort();
|
||||
|
||||
describe("getEntities", () => {
|
||||
const hass = makeHass([
|
||||
"light.kitchen",
|
||||
"light.living",
|
||||
"switch.fan",
|
||||
"sensor.temp",
|
||||
]);
|
||||
|
||||
it("returns all entities when no filters are given", () => {
|
||||
expect(ids(getEntities(hass))).toEqual([
|
||||
"light.kitchen",
|
||||
"light.living",
|
||||
"sensor.temp",
|
||||
"switch.fan",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by includeDomains", () => {
|
||||
expect(ids(getEntities(hass, { includeDomains: ["light"] }))).toEqual([
|
||||
"light.kitchen",
|
||||
"light.living",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by excludeDomains", () => {
|
||||
expect(
|
||||
ids(getEntities(hass, { excludeDomains: ["light", "switch"] }))
|
||||
).toEqual(["sensor.temp"]);
|
||||
});
|
||||
|
||||
it("filters by includeEntities", () => {
|
||||
expect(
|
||||
ids(
|
||||
getEntities(hass, {
|
||||
includeEntities: ["light.kitchen", "sensor.temp"],
|
||||
})
|
||||
)
|
||||
).toEqual(["light.kitchen", "sensor.temp"]);
|
||||
});
|
||||
|
||||
it("filters by excludeEntities", () => {
|
||||
expect(
|
||||
ids(
|
||||
getEntities(hass, {
|
||||
excludeEntities: ["light.kitchen", "light.living"],
|
||||
})
|
||||
)
|
||||
).toEqual(["sensor.temp", "switch.fan"]);
|
||||
});
|
||||
|
||||
it("combines include and exclude filters", () => {
|
||||
expect(
|
||||
ids(
|
||||
getEntities(hass, {
|
||||
includeDomains: ["light"],
|
||||
excludeEntities: ["light.living"],
|
||||
})
|
||||
)
|
||||
).toEqual(["light.kitchen"]);
|
||||
});
|
||||
|
||||
it("applies idPrefix to the item id", () => {
|
||||
const items = getEntities(hass, {
|
||||
includeEntities: ["sensor.temp"],
|
||||
idPrefix: "entity-",
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe("entity-sensor.temp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeCssVariable,
|
||||
computeCssValue,
|
||||
} from "../../src/resources/css-variables";
|
||||
|
||||
describe("computeCssVariable", () => {
|
||||
it("wraps a single property in var()", () => {
|
||||
expect(computeCssVariable("--primary-color")).toBe("var(--primary-color)");
|
||||
});
|
||||
|
||||
it("builds a nested fallback chain in order for an array", () => {
|
||||
expect(computeCssVariable(["--a", "--b", "--c"])).toBe(
|
||||
"var(--a, var(--b, var(--c)))"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-element array", () => {
|
||||
expect(computeCssVariable(["--only"])).toBe("var(--only)");
|
||||
});
|
||||
|
||||
it("returns undefined for an empty array", () => {
|
||||
expect(computeCssVariable([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const props = ["--a", "--b", "--c"];
|
||||
computeCssVariable(props);
|
||||
expect(props).toEqual(["--a", "--b", "--c"]);
|
||||
});
|
||||
|
||||
it("returns the same result when called repeatedly with the same array", () => {
|
||||
const props = ["--a", "--b", "--c"];
|
||||
const first = computeCssVariable(props);
|
||||
const second = computeCssVariable(props);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCssValue", () => {
|
||||
const computedStyles = {
|
||||
getPropertyValue: (prop: string) =>
|
||||
({
|
||||
"--a-color": " red ",
|
||||
"--b-color": "blue",
|
||||
})[prop] ?? "",
|
||||
} as CSSStyleDeclaration;
|
||||
|
||||
it("returns the trimmed value of a color property", () => {
|
||||
expect(computeCssValue("--a-color", computedStyles)).toBe("red");
|
||||
});
|
||||
|
||||
it("ignores properties that do not end with -color", () => {
|
||||
expect(computeCssValue("--a-size", computedStyles)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the first resolved value from an array", () => {
|
||||
expect(
|
||||
computeCssValue(["--missing-color", "--b-color"], computedStyles)
|
||||
).toBe("blue");
|
||||
});
|
||||
|
||||
it("returns undefined when no property resolves", () => {
|
||||
expect(
|
||||
computeCssValue(
|
||||
["--missing-color", "--also-missing-color"],
|
||||
computedStyles
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLogMessage } from "../../src/resources/log-message";
|
||||
|
||||
const fromError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("stacktrace-js", () => ({ fromError }));
|
||||
|
||||
describe("createLogMessage", () => {
|
||||
beforeEach(() => {
|
||||
fromError.mockReset();
|
||||
});
|
||||
|
||||
it("includes the error message and parsed stack frames", async () => {
|
||||
fromError.mockResolvedValue([
|
||||
{ fileName: "https://example.com/foo.js", toString: () => "at foo.js" },
|
||||
]);
|
||||
const error = new Error("boom");
|
||||
|
||||
const message = await createLogMessage(error);
|
||||
|
||||
expect(message).toContain("Error: boom");
|
||||
expect(message).toContain("at foo.js");
|
||||
});
|
||||
|
||||
it("does not throw when stacktrace-js cannot parse the stack", async () => {
|
||||
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
|
||||
const error = new Error("boom");
|
||||
error.stack = "Error: boom\n at <anonymous>";
|
||||
|
||||
const message = await createLogMessage(error);
|
||||
|
||||
expect(message).toContain("Error: boom");
|
||||
// Falls back to the raw stack instead of crashing the logger.
|
||||
expect(message).toContain("at <anonymous>");
|
||||
});
|
||||
|
||||
it("falls back to the provided stack fallback when no stack is available", async () => {
|
||||
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
|
||||
const error = new Error("boom");
|
||||
error.stack = undefined;
|
||||
|
||||
const message = await createLogMessage(
|
||||
error,
|
||||
undefined,
|
||||
undefined,
|
||||
"@unknown:0:0"
|
||||
);
|
||||
|
||||
expect(message).toContain("@unknown:0:0");
|
||||
});
|
||||
});
|
||||
@@ -1676,13 +1676,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-datetimeformat@npm:7.4.8":
|
||||
version: 7.4.8
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.4.8"
|
||||
"@formatjs/intl-datetimeformat@npm:7.4.9":
|
||||
version: 7.4.9
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.4.9"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.10"
|
||||
checksum: 10/9dde6796f1e260fbb486f27b1a5774a70aef2b4259b102b745b495d93ea5881f0df80d133bf92138cb003c77b7a016f125562f20360a92125680cc7f54621971
|
||||
checksum: 10/e8739e71f472f1b4beb871c5920b612481c04b440daca01aaadf562b5fdb262e1d40505d767c447595490129ca31b975a1cacb8eef04b50fb307535155f58cae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -1885,73 +1885,73 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/core@npm:^0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/core@npm:0.61.0"
|
||||
"@html-eslint/core@npm:^0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/core@npm:0.62.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.61.0"
|
||||
"@html-eslint/types": "npm:^0.62.0"
|
||||
html-standard: "npm:^0.0.13"
|
||||
checksum: 10/a464a9d06c808dd13bfb6a5b050d7bd0d51e6d43b239337036296ab01a4e45fac2a98bc8bb0b47894333b57ec35f970d07a5ecafc1be7d116a88801899cac301
|
||||
checksum: 10/8899d20b7b5e0723e0f030b6007855f442633f6eea29835b260aa5f678076d88fb5b2c697b4238b868a5c4c5f929330767022c53a98c3ef21d06b84fa1466431
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/eslint-plugin@npm:0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/eslint-plugin@npm:0.61.0"
|
||||
"@html-eslint/eslint-plugin@npm:0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/eslint-plugin@npm:0.62.0"
|
||||
dependencies:
|
||||
"@eslint/plugin-kit": "npm:^0.4.1"
|
||||
"@html-eslint/core": "npm:^0.61.0"
|
||||
"@html-eslint/parser": "npm:^0.61.0"
|
||||
"@html-eslint/template-parser": "npm:^0.61.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.61.0"
|
||||
"@html-eslint/types": "npm:^0.61.0"
|
||||
"@html-eslint/core": "npm:^0.62.0"
|
||||
"@html-eslint/parser": "npm:^0.62.0"
|
||||
"@html-eslint/template-parser": "npm:^0.62.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.62.0"
|
||||
"@html-eslint/types": "npm:^0.62.0"
|
||||
"@rviscomi/capo.js": "npm:^2.1.0"
|
||||
html-standard: "npm:^0.0.13"
|
||||
peerDependencies:
|
||||
eslint: ">=8.0.0 || ^10.0.0-0"
|
||||
checksum: 10/ef2a4e550ecea2d8dea786a09a68ffbd3cd7fae0d9bed05e012e37dcffaa358aaeba5e61ecae2029e7522e14d709971511d065ada3fd1dd1c0caed8496f4bc3d
|
||||
checksum: 10/3a103e6ea40632a562d9123a3645685c0d986f0ae1794df4d6a074fa40f2ce934059ad49df92d58c00c34d1d59ee7491fa68a74dd3c821a9f9d6105e8f4a608c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/parser@npm:^0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/parser@npm:0.61.0"
|
||||
"@html-eslint/parser@npm:^0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/parser@npm:0.62.0"
|
||||
dependencies:
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.61.0"
|
||||
"@html-eslint/types": "npm:^0.61.0"
|
||||
"@html-eslint/template-syntax-parser": "npm:^0.62.0"
|
||||
"@html-eslint/types": "npm:^0.62.0"
|
||||
css-tree: "npm:^3.1.0"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/d0b864b159e2b69ed602a00e1cdfd842b1a67bbc85d5105376090a146da313f47107fb6a265219a9be1e60bcf97f5500637c91aac9abd4b25a873c0d69a5e237
|
||||
checksum: 10/dd248ab52caa4d00c20d57ec9d62c7b677479a0a04809e33324f50bfe0b5cfcafa8d5a84a70eb3d90af2b8c72adc694578033c1d2b3365942eec4dd1c2e432ea
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/template-parser@npm:^0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/template-parser@npm:0.61.0"
|
||||
"@html-eslint/template-parser@npm:^0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/template-parser@npm:0.62.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.61.0"
|
||||
"@html-eslint/types": "npm:^0.62.0"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/f44c7e9903366fc0f02c89fdc096e66f26f61d3be5fb88f582d39a5f3d6d831c33a60162005e5fbd643c70368929c6b568c97c5bc2fece5e789c9c2ab7aa9c27
|
||||
checksum: 10/6da6d9ce0fd894a6eeda4d9f3a439aa65281cd84124b724709ea1d09c7bac5c391fef99115e35d2e1e1d3bf2821e315c83a50400c3dd537ef5c266a598ae43d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/template-syntax-parser@npm:^0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/template-syntax-parser@npm:0.61.0"
|
||||
"@html-eslint/template-syntax-parser@npm:^0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/template-syntax-parser@npm:0.62.0"
|
||||
dependencies:
|
||||
"@html-eslint/types": "npm:^0.61.0"
|
||||
checksum: 10/d0b5c3fea0906e23c5cf98efcaee36933cfb2a436a6f3a09aa07854cced4616166827a83853b7f7f6dede0b398cb73dc290086ed0d030781fb68324f0ebcc7d4
|
||||
"@html-eslint/types": "npm:^0.62.0"
|
||||
checksum: 10/a6cd8729be8cca7e923278ffa5ba2c3b9898a9e0d55dc6565319fb411a00079ff64ef702f15dca9be4891d33d720a5f5893fc2b55040d3ad29d91c4754d9af97
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@html-eslint/types@npm:^0.61.0":
|
||||
version: 0.61.0
|
||||
resolution: "@html-eslint/types@npm:0.61.0"
|
||||
"@html-eslint/types@npm:^0.62.0":
|
||||
version: 0.62.0
|
||||
resolution: "@html-eslint/types@npm:0.62.0"
|
||||
dependencies:
|
||||
"@types/css-tree": "npm:^2.3.11"
|
||||
"@types/estree": "npm:^1.0.6"
|
||||
es-html-parser: "npm:0.3.1"
|
||||
checksum: 10/6c57f3363dc938ecd0cdfce4bd0f38c29aa05a4f80449af9450f2514f8692739af3c4e82a5764707aa2fb92b8442efdc80b2cebfec96060e8957ef5ac72d1aee
|
||||
checksum: 10/4021626f1d075adf3785f6df5496495ffe25ded68dd09eab99dddd6a175e3346aea4370c69cf8f176fc8325510ee8c85733c086c215a6d9f455b55e1cc0fcfdb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3456,7 +3456,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsbuild/plugin-check-syntax@npm:1.6.1":
|
||||
"@rsbuild/plugin-check-syntax@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsbuild/plugin-check-syntax@npm:1.6.1"
|
||||
dependencies:
|
||||
@@ -3474,83 +3474,83 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/client@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/client@npm:1.5.12"
|
||||
checksum: 10/df3dbabf3629ec8bd98a6eb46ffac1a3b7019c99b24be12bc67e5d9bc2289d0b3a925ee658609257df47187e614fa53ad3123f07e4b90d3fc5b79b009863a19c
|
||||
"@rsdoctor/client@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/client@npm:1.5.13"
|
||||
checksum: 10/3c2917c79a4fe371a83cbead6263705a0761814710a807e61d5694090dae677ca7c73427e4470f587cc313ea2326f80018fb18475fcb2963e2ac39dc3f818fa9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/core@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/core@npm:1.5.12"
|
||||
"@rsdoctor/core@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/core@npm:1.5.13"
|
||||
dependencies:
|
||||
"@rsbuild/plugin-check-syntax": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.5.12"
|
||||
"@rsdoctor/sdk": "npm:1.5.12"
|
||||
"@rsdoctor/types": "npm:1.5.12"
|
||||
"@rsdoctor/utils": "npm:1.5.12"
|
||||
"@rspack/resolver": "npm:0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.1"
|
||||
es-toolkit: "npm:^1.45.1"
|
||||
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.5.13"
|
||||
"@rsdoctor/sdk": "npm:1.5.13"
|
||||
"@rsdoctor/types": "npm:1.5.13"
|
||||
"@rsdoctor/utils": "npm:1.5.13"
|
||||
"@rspack/resolver": "npm:^0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.2"
|
||||
es-toolkit: "npm:^1.47.0"
|
||||
filesize: "npm:^11.0.17"
|
||||
fs-extra: "npm:^11.1.1"
|
||||
semver: "npm:^7.7.4"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/1e767fc250e30d34ca7821498ba66ecf9695db46d14dc050e02fcaf04c3cc74e783ec6298ce98e8eca96ff30ef32f7c166ad0d3ad33bf4d55a6bdcf65ca99dfb
|
||||
checksum: 10/5a482394a7c9374cff14d4422277d2d8fbcd0397325e936a6785871800e0891633a79bc850c331a2190b4b362efedeac4d9240f2f0255a6482e36cfbadee0874
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/graph@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/graph@npm:1.5.12"
|
||||
"@rsdoctor/graph@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/graph@npm:1.5.13"
|
||||
dependencies:
|
||||
"@rsdoctor/types": "npm:1.5.12"
|
||||
"@rsdoctor/utils": "npm:1.5.12"
|
||||
es-toolkit: "npm:^1.45.1"
|
||||
"@rsdoctor/types": "npm:1.5.13"
|
||||
"@rsdoctor/utils": "npm:1.5.13"
|
||||
es-toolkit: "npm:^1.47.0"
|
||||
path-browserify: "npm:1.0.1"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/8eddfdb217a36f746e1a5e4dd8e9834b8a5a7b5dd55f8db5e6016ced2dc8bd87928848677315ad5246bd76754cde9703b327f47318d8d6b94c8a21b4d1d5b623
|
||||
checksum: 10/0e4bb1c053a580f37fb2df5c054c4869da2c72f8a51ed86dc097f0309ca9f1621523026747e7d05dd5244cd6d6b929e2ba585b1d16004e81723e3c8d4c43adb4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/rspack-plugin@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.5.12"
|
||||
"@rsdoctor/rspack-plugin@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.5.13"
|
||||
dependencies:
|
||||
"@rsdoctor/core": "npm:1.5.12"
|
||||
"@rsdoctor/graph": "npm:1.5.12"
|
||||
"@rsdoctor/sdk": "npm:1.5.12"
|
||||
"@rsdoctor/types": "npm:1.5.12"
|
||||
"@rsdoctor/utils": "npm:1.5.12"
|
||||
"@rsdoctor/core": "npm:1.5.13"
|
||||
"@rsdoctor/graph": "npm:1.5.13"
|
||||
"@rsdoctor/sdk": "npm:1.5.13"
|
||||
"@rsdoctor/types": "npm:1.5.13"
|
||||
"@rsdoctor/utils": "npm:1.5.13"
|
||||
peerDependencies:
|
||||
"@rspack/core": "*"
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
checksum: 10/808d1b06e5016d02f5bbeb0c1883ab88b802ee15c9d6cad5787446c689af8d44ce1950d472b645bbc0e8e45a507f226678f750708c2a65c49fcc6dca21436aa4
|
||||
checksum: 10/539e98a69babf928f3c74f8f395c1e7375fc0021ba6f3bcbd95398c005ebc32187ee814cbefcabc3db7c8f01c10658be59be86327432fb282577493535b9722e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/sdk@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/sdk@npm:1.5.12"
|
||||
"@rsdoctor/sdk@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/sdk@npm:1.5.13"
|
||||
dependencies:
|
||||
"@rsdoctor/client": "npm:1.5.12"
|
||||
"@rsdoctor/graph": "npm:1.5.12"
|
||||
"@rsdoctor/types": "npm:1.5.12"
|
||||
"@rsdoctor/utils": "npm:1.5.12"
|
||||
"@rsdoctor/client": "npm:1.5.13"
|
||||
"@rsdoctor/graph": "npm:1.5.13"
|
||||
"@rsdoctor/types": "npm:1.5.13"
|
||||
"@rsdoctor/utils": "npm:1.5.13"
|
||||
launch-editor: "npm:^2.13.2"
|
||||
safer-buffer: "npm:2.1.2"
|
||||
socket.io: "npm:4.8.1"
|
||||
tapable: "npm:2.3.3"
|
||||
checksum: 10/c37158ab3c524d095e8844d96273632c5e10516ac93c79f4e5ae09cd9e52a60e34393b40fe5f83c058d08d82757c42eb9ff7080269a40bdef6191e8f19236704
|
||||
checksum: 10/02245b485add981c3cfc2fab613fea9f58659c1cc48aedb7dfe472c173c0f8d0455bab4fc638fa096e461142e0e21465705ec29eba9372546c6699792e6fff25
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/types@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/types@npm:1.5.12"
|
||||
"@rsdoctor/types@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/types@npm:1.5.13"
|
||||
dependencies:
|
||||
"@types/connect": "npm:3.4.38"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
@@ -3564,16 +3564,16 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 10/f9a7c5680b349b9341547980c5e70280ea3344299a017b3f03e622003f057ef1c004b34400fad295d7457f866007c83dab9c8f0f03b9f7d95e1800d63c8097f1
|
||||
checksum: 10/c3e2f35d8157ba833926b55265faee27fad877e196dba2f7155c246d7440208d0fa42e5d7809bb1ab698ddfe054c41d8813f10435a4f6f110294a1bd4cfd24c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/utils@npm:1.5.12":
|
||||
version: 1.5.12
|
||||
resolution: "@rsdoctor/utils@npm:1.5.12"
|
||||
"@rsdoctor/utils@npm:1.5.13":
|
||||
version: 1.5.13
|
||||
resolution: "@rsdoctor/utils@npm:1.5.13"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.26.2"
|
||||
"@rsdoctor/types": "npm:1.5.12"
|
||||
"@rsdoctor/types": "npm:1.5.13"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
acorn: "npm:^8.10.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
@@ -3585,57 +3585,57 @@ __metadata:
|
||||
json-stream-stringify: "npm:3.0.1"
|
||||
lines-and-columns: "npm:2.0.4"
|
||||
picocolors: "npm:^1.1.1"
|
||||
rslog: "npm:^2.1.1"
|
||||
rslog: "npm:^2.1.2"
|
||||
strip-ansi: "npm:^6.0.1"
|
||||
checksum: 10/11dc306eb6e2c325b644f45d57e5afb4e7c7d38924d52a8ebdef4276406074ff700c218b3bf7eb875cd9ceab3e546cb8c664df245076dca065dabceacd378feb
|
||||
checksum: 10/59289ae18a781dd44510c3171e69eff63645cc76cad093d0b078a3fb8d2d0f12b5e08d333606b25f9b1755a980453ca6edd0629a13725712a113eaa8f3de2e2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.0.6"
|
||||
"@rspack/binding-darwin-arm64@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.0.8"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.0.6"
|
||||
"@rspack/binding-darwin-x64@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.0.8"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.6"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.8"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.6"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.8"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.6"
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.8"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.0.6"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.0.8"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.0.6"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.0.8"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.10.0"
|
||||
"@emnapi/runtime": "npm:1.10.0"
|
||||
@@ -3644,41 +3644,41 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.6"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.8"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.6"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.8"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.6"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.8"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/binding@npm:2.0.6"
|
||||
"@rspack/binding@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/binding@npm:2.0.8"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.0.6"
|
||||
"@rspack/binding-darwin-x64": "npm:2.0.6"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.0.6"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.0.6"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.0.6"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.0.6"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.0.6"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.0.6"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.0.6"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.0.6"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.0.8"
|
||||
"@rspack/binding-darwin-x64": "npm:2.0.8"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.0.8"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.0.8"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.0.8"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.0.8"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.0.8"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.0.8"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.0.8"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.0.8"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -3700,15 +3700,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/c2e5245abab3257d02f5d98947fad26c8de1b18bb17362734035cfbdd725d9c6c78432372bdff985b32fa4062059d7210e9f5ea7314ae3080805b64f616fe348
|
||||
checksum: 10/aface75866ff0bcd4934fda26e856e8de63e710a1489e654f1c6e5108d6ca46d2183b01aad2a76db1511e99843522272882ece53c2a4cf9fbfe0ac5ab5bcd5c2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.0.6":
|
||||
version: 2.0.6
|
||||
resolution: "@rspack/core@npm:2.0.6"
|
||||
"@rspack/core@npm:2.0.8":
|
||||
version: 2.0.8
|
||||
resolution: "@rspack/core@npm:2.0.8"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.0.6"
|
||||
"@rspack/binding": "npm:2.0.8"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -3717,7 +3717,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/d2417690e8135342179bc9e5035e16fe827522b4c0babef029a21ff5903cd56c09b86f08924527bd7d3e66f178f1f678ce099199cac8c1a137b18c5d8892e613
|
||||
checksum: 10/93e34b878dbc69c12f9b06909354246597a5c387c5df77f61e56f3a20e2b45434b9fa8734866f4e662ab0e3456064bf8133ae6f58a3afffee5053a27b8395195
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3827,7 +3827,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/resolver@npm:0.2.8":
|
||||
"@rspack/resolver@npm:^0.2.8":
|
||||
version: 0.2.8
|
||||
resolution: "@rspack/resolver@npm:0.2.8"
|
||||
dependencies:
|
||||
@@ -5810,10 +5810,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"browserslist-load-config@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "browserslist-load-config@npm:1.0.1"
|
||||
checksum: 10/872d2978d2546eb02920b7124d8269e10b3a8d26c1426f1ca844c0d4db53929789d1df5acd0b322b464af18264b58d0f3038a54656fe160c6dc1ab18b2d9491f
|
||||
"browserslist-load-config@npm:^1.0.2":
|
||||
version: 1.0.3
|
||||
resolution: "browserslist-load-config@npm:1.0.3"
|
||||
checksum: 10/3e981e30c09e802ff881f04a992cbbe40004b3086d7f5ae1d0c04ae3d00dc49d2068f3f79ce6bec4f73f77998451cd03d83b2f677e4ef116ef0dcfafd976b8c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7197,15 +7197,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-toolkit@npm:^1.45.1":
|
||||
version: 1.46.1
|
||||
resolution: "es-toolkit@npm:1.46.1"
|
||||
"es-toolkit@npm:^1.47.0":
|
||||
version: 1.47.0
|
||||
resolution: "es-toolkit@npm:1.47.0"
|
||||
dependenciesMeta:
|
||||
"@trivago/prettier-plugin-sort-imports@4.3.0":
|
||||
unplugged: true
|
||||
prettier-plugin-sort-re-exports@0.0.1:
|
||||
unplugged: true
|
||||
checksum: 10/15fa8e58848c3cf3f56b3fca6505362a7e19a6487613cd928197d11a12066010655ee47f74e5f412d949173f998df7ce7babcba9ff838bd40ce4ca79fca8f3c4
|
||||
vitepress-plugin-sandpack@1.1.4:
|
||||
unplugged: true
|
||||
checksum: 10/3dcb898b69cb84fd5bd8a18a5a63b01d0b9fc4a74539d01c58869f5460e1402a6eb7b1260729a564043a6776c24c04b96d72883918c1fbed9ab5c91a5c064f80
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8449,7 +8451,7 @@ __metadata:
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.4.8"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.4.9"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.10"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.14"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
@@ -8465,7 +8467,7 @@ __metadata:
|
||||
"@fullcalendar/luxon3": "npm:6.1.20"
|
||||
"@fullcalendar/timegrid": "npm:6.1.20"
|
||||
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
|
||||
"@html-eslint/eslint-plugin": "npm:0.61.0"
|
||||
"@html-eslint/eslint-plugin": "npm:0.62.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.1.0"
|
||||
"@lit-labs/observers": "npm:2.1.0"
|
||||
@@ -8482,8 +8484,8 @@ __metadata:
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.5.12"
|
||||
"@rspack/core": "npm:2.0.6"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.5.13"
|
||||
"@rspack/core": "npm:2.0.8"
|
||||
"@rspack/dev-server": "npm:2.0.3"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
@@ -8570,7 +8572,7 @@ __metadata:
|
||||
node-vibrant: "npm:4.0.4"
|
||||
object-hash: "npm:3.0.0"
|
||||
pinst: "npm:3.0.0"
|
||||
prettier: "npm:3.8.3"
|
||||
prettier: "npm:3.8.4"
|
||||
punycode: "npm:2.3.1"
|
||||
qr-scanner: "npm:1.4.2"
|
||||
qrcode: "npm:1.5.4"
|
||||
@@ -11350,12 +11352,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:3.8.3":
|
||||
version: 3.8.3
|
||||
resolution: "prettier@npm:3.8.3"
|
||||
"prettier@npm:3.8.4":
|
||||
version: 3.8.4
|
||||
resolution: "prettier@npm:3.8.4"
|
||||
bin:
|
||||
prettier: bin/prettier.cjs
|
||||
checksum: 10/4b3b12cbb29e4c96bed936e5d070167552500c18d37676fb3e0caae6199c42860662608e4dc116230698f6e2bb0267ef2548158224c92d40f188d309d72fdd6f
|
||||
checksum: 10/54684a3cc6689238692b29fab541c01934af7677be94c02293ba49981a1ac121c8bebe2a865f0c3b963e99d208f847c53aed354cc0ce8750e2d45791d64506c5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12012,10 +12014,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rslog@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "rslog@npm:2.1.1"
|
||||
checksum: 10/acdbcca91710a468eff2fc4441ed73991e1c2e234708de2aeda2b6c59469d3c4bce7b10bbad14dc97a52f613c0516e7bfd1049594f84766c9d65cf6ac6aa37e1
|
||||
"rslog@npm:^2.1.2":
|
||||
version: 2.1.3
|
||||
resolution: "rslog@npm:2.1.3"
|
||||
checksum: 10/2d64dc30e425665854619be879c58bf37bf6d76f7556d5cf41f97f221dd0ec0f4c43e0b733e14c536b8967878f8c9e1f717ddcb0de23239aa83cc9c3e96051f2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user