mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-22 11:06:55 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23a82918f4 | |||
| 5e6840ed9c | |||
| ba287779d4 | |||
| 312fc8d2fb | |||
| 1109ae25cb | |||
| fd3b25d13f | |||
| 0f31fb50f8 | |||
| f38a6cf44b | |||
| 8eab8a5505 | |||
| 17fa5dbabf | |||
| a1a15ae674 | |||
| b421a96eb0 | |||
| 7dbfb2f9d2 | |||
| c68cd67cbd | |||
| 6de9841b6e | |||
| 35193447bf | |||
| 650444e92d | |||
| da9a78f752 | |||
| 9077290b39 | |||
| e4c6ea29b3 | |||
| aa57c27739 | |||
| 420137478e | |||
| 25bc81cf1f | |||
| 6b0f543fdb | |||
| bbd26ce5a9 | |||
| d3c23dd704 | |||
| 7d4e5dc03f | |||
| db22be5a30 | |||
| b896b20409 | |||
| cb3b528ba3 | |||
| 2ed8e0bd0d | |||
| adc3393422 | |||
| 06b9b08809 | |||
| ece84d42dc |
@@ -22,7 +22,8 @@ fireEvent(this, "show-dialog", {
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
|
||||
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
|
||||
@@ -33,13 +33,13 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when its configured URL responds; otherwise it performs a slow full build. When a development watcher is being reused, rspack recompiles on save and reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
@@ -49,7 +49,7 @@ Start the relevant suite server, then run that suite:
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
@@ -15,6 +15,10 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/AGENTS.md"
|
||||
- "**/CLAUDE.md"
|
||||
- "**/GEMINI.md"
|
||||
- .agents/**
|
||||
- .claude/**
|
||||
- .github/agents/**
|
||||
- .github/copilot-instructions.md
|
||||
- .github/hooks/**
|
||||
- .github/instructions/**
|
||||
- .github/plugin.json
|
||||
- .github/plugin/**
|
||||
- .github/prompts/**
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: github.event_name != 'push' || github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -50,3 +50,8 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
|
||||
## AI policy
|
||||
|
||||
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
|
||||
Autonomous contributions are not accepted: a human must review, understand, and be able to explain every change before it is submitted. Do not open issues or pull requests autonomously, and do not post comments on behalf of a user without their review.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Open Home Foundation - AI Policy
|
||||
|
||||
We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
|
||||
|
||||
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
|
||||
|
||||
## Autonomous agents
|
||||
|
||||
**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
|
||||
|
||||
## Communication on issues, pull requests, and code reviews
|
||||
|
||||
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
|
||||
|
||||
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
|
||||
|
||||
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
|
||||
|
||||
## Non-native English speakers
|
||||
|
||||
We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
|
||||
|
||||
## Code and documentation contributions
|
||||
|
||||
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
|
||||
|
||||
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
|
||||
|
||||
## Our use of AI
|
||||
|
||||
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
|
||||
|
||||
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss.
|
||||
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
+13
-13
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -102,12 +102,12 @@
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.4.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -146,13 +146,13 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rsdoctor/rspack-plugin": "1.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -211,7 +211,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
const regexp =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
// Unanchored regex fragments, shared as the single source of truth for both
|
||||
// the boolean validators below and the HTML `pattern` attribute (the browser
|
||||
// anchors a pattern as `^(?:…)$`).
|
||||
const IPV4 =
|
||||
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
|
||||
|
||||
export const isIPAddress = (input: string): boolean => regexp.test(input);
|
||||
const IPV6 =
|
||||
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
|
||||
|
||||
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
|
||||
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
|
||||
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
|
||||
|
||||
// IPv4 or IPv6 address.
|
||||
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
|
||||
|
||||
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
|
||||
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
|
||||
|
||||
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
|
||||
|
||||
const ipv4Regexp = anchored(IPV4);
|
||||
const ipv6Regexp = anchored(IPV6);
|
||||
|
||||
// IPv4 address, e.g. 192.168.1.10
|
||||
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
|
||||
|
||||
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
|
||||
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
|
||||
|
||||
// IPv4 or IPv6 address
|
||||
export const isIPAddressV4OrV6 = (input: string): boolean =>
|
||||
isIPAddress(input) || isIPv6Address(input);
|
||||
|
||||
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
|
||||
export const isIPNetwork = (input: string): boolean => {
|
||||
const parts = input.split("/");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
const [address, prefix] = parts;
|
||||
if (!/^\d{1,3}$/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const prefixLength = Number(prefix);
|
||||
if (isIPAddress(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 32;
|
||||
}
|
||||
if (isIPv6Address(address)) {
|
||||
return prefixLength >= 0 && prefixLength <= 128;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,40 @@ interface ClockDatePartSectionData {
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out sections whose group already has a value in `value`, so only
|
||||
* one non-separator value per group can be selected in the picker. The whole
|
||||
* group of the item at `excludeIndex` (the one being edited) stays
|
||||
* selectable, even if that group has more than one value in `value` (e.g. a
|
||||
* pre-existing config authored outside the picker via YAML).
|
||||
* The separator group is always kept.
|
||||
*/
|
||||
export const getAvailableClockDatePartSections = (
|
||||
sections: ClockDatePartSectionData[],
|
||||
value: string[],
|
||||
excludeIndex?: number
|
||||
): ClockDatePartSectionData[] => {
|
||||
const editedItem = excludeIndex != null ? value[excludeIndex] : undefined;
|
||||
const editedSection = editedItem
|
||||
? getClockDatePartSection(editedItem as ClockCardDatePart)
|
||||
: undefined;
|
||||
|
||||
const usedSections = new Set<ClockDatePartSection>();
|
||||
|
||||
value.forEach((item) => {
|
||||
const section = getClockDatePartSection(item as ClockCardDatePart);
|
||||
|
||||
if (section !== "separator" && section !== editedSection) {
|
||||
usedSections.add(section);
|
||||
}
|
||||
});
|
||||
|
||||
return sections.filter(
|
||||
(sectionData) =>
|
||||
sectionData.id === "separator" || !usedSections.has(sectionData.id)
|
||||
);
|
||||
};
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
@@ -103,12 +137,17 @@ export class HaClockDateFormatPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
@state() private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
const pickerSections = getAvailableClockDatePartSections(
|
||||
sections,
|
||||
value,
|
||||
this._editIndex
|
||||
);
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
@@ -117,8 +156,8 @@ export class HaClockDateFormatPicker extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(sections)}
|
||||
.getItems=${this._getItems(sections)}
|
||||
.sections=${this._getSectionHeaders(pickerSections)}
|
||||
.getItems=${this._getItems(pickerSections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === undefined || value === null || value === "";
|
||||
|
||||
const matchFieldCondition = (
|
||||
condition: HaFormFieldCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const actual = data?.[condition.field];
|
||||
switch (condition.operator ?? "eq") {
|
||||
case "eq":
|
||||
return actual === condition.value;
|
||||
case "not_eq":
|
||||
return actual !== condition.value;
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
condition.value.includes(actual as any)
|
||||
);
|
||||
case "not_in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
!condition.value.includes(actual as any)
|
||||
);
|
||||
case "exists":
|
||||
return !isEmpty(actual);
|
||||
case "not_exists":
|
||||
return isEmpty(actual);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluateCondition = (
|
||||
condition: HaFormCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
if ("condition" in condition) {
|
||||
switch (condition.condition) {
|
||||
case "and":
|
||||
return condition.conditions.every((c) => evaluateCondition(c, data));
|
||||
case "or":
|
||||
return condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
case "not":
|
||||
return !condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isFieldHidden = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { hidden } = schema as HaFormBaseSchema;
|
||||
if (!hidden) {
|
||||
return false;
|
||||
}
|
||||
if (hidden === true) {
|
||||
return true;
|
||||
}
|
||||
const conditions = Array.isArray(hidden) ? hidden : [hidden];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this.schema.schema.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
${this.schema.schema
|
||||
.filter((item) => !isFieldHidden(item, this.data))
|
||||
.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
this.schema.forEach((item, index) => {
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (isFieldHidden(item, this.data)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const error = getError(this.error, item);
|
||||
const warning = getWarning(this.warning, item);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is hidden while the condition holds. Serializable so it can be
|
||||
// shared with the backend and other renderers.
|
||||
hidden?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
export interface HaFormFieldCondition {
|
||||
field: string;
|
||||
operator?: HaFormConditionOperator;
|
||||
value?: HaFormData | readonly HaFormData[];
|
||||
}
|
||||
|
||||
export interface HaFormAndCondition {
|
||||
condition: "and";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormOrCondition {
|
||||
condition: "or";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormNotCondition {
|
||||
condition: "not";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export type HaFormCondition =
|
||||
| HaFormFieldCondition
|
||||
| HaFormAndCondition
|
||||
| HaFormOrCondition
|
||||
| HaFormNotCondition;
|
||||
|
||||
export interface HaFormGridSchema extends HaFormBaseSchema {
|
||||
type: "grid";
|
||||
flatten?: boolean;
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaAreaSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.area.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -147,7 +147,13 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean =>
|
||||
ensureArray(this.selector.device!.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -23,6 +27,8 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -37,12 +43,38 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
return (
|
||||
selector.entity?.filter &&
|
||||
ensureArray(selector.entity.filter).some((filter) => filter.integration)
|
||||
);
|
||||
}
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(
|
||||
entitySources: EntitySources,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntries?: ConfigEntry[]
|
||||
) =>
|
||||
getDeviceIntegrationLookup(
|
||||
entitySources,
|
||||
Object.values(entities),
|
||||
Object.values(devices),
|
||||
configEntries
|
||||
)
|
||||
);
|
||||
|
||||
// Which async data the current filter needs to be evaluated: a top-level or
|
||||
// device `integration` filter needs entity sources, and a `device.integration`
|
||||
// filter additionally needs config entries (the device integration lookup is
|
||||
// built from both).
|
||||
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
|
||||
const filters = selector.entity?.filter
|
||||
? ensureArray(selector.entity.filter)
|
||||
: [];
|
||||
return {
|
||||
entitySources: filters.some(
|
||||
(f) => f.integration || f.device?.integration
|
||||
),
|
||||
configEntries: filters.some((f) => f.device?.integration),
|
||||
};
|
||||
});
|
||||
|
||||
private _fetchedConfigEntries = false;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||
@@ -57,7 +89,11 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._hasIntegration(this.selector) && !this._entitySources) {
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
if (
|
||||
(needs.entitySources && !this._entitySources) ||
|
||||
(needs.configEntries && !this._configEntries)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -96,15 +132,37 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
) {
|
||||
|
||||
// The connection changed (e.g. reconnect); refetch config entries.
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (oldHass && oldHass.connection !== this.hass.connection) {
|
||||
this._fetchedConfigEntries = false;
|
||||
this._configEntries = undefined;
|
||||
}
|
||||
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
|
||||
if (needs.entitySources && !this._entitySources) {
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
}
|
||||
|
||||
if (needs.configEntries && !this._fetchedConfigEntries) {
|
||||
this._fetchedConfigEntries = true;
|
||||
getConfigEntries(this.hass)
|
||||
.then((entries) => {
|
||||
this._configEntries = entries;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to no entries so the picker still renders. We keep
|
||||
// `_fetchedConfigEntries` set so the failed fetch is not retried on
|
||||
// every re-render; the connection-change handler above retries on
|
||||
// reconnect.
|
||||
this._configEntries = [];
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
}
|
||||
@@ -114,8 +172,25 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrationLookup =
|
||||
this._entitySources && this._dataNeeds(this.selector).configEntries
|
||||
? this._deviceIntegrationLookup(
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this._configEntries
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ensureArray(this.selector.entity.filter).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
deviceIntegrationLookup
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaFloorSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.floor.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -93,7 +93,13 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.target.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
|
||||
|
||||
@query("ha-input-multi") private _inputMulti?: {
|
||||
reportValidity: () => boolean;
|
||||
};
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this._input?.focus();
|
||||
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
|
||||
|
||||
public reportValidity(): boolean {
|
||||
if (this.selector.text?.multiple) {
|
||||
return true;
|
||||
return this._inputMulti?.reportValidity() ?? true;
|
||||
}
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
|
||||
.inputPrefix=${this.selector.text?.prefix}
|
||||
.helper=${this.helper}
|
||||
.autocomplete=${this.selector.text?.autocomplete}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
@value-changed=${this._handleChange}
|
||||
>
|
||||
</ha-input-multi>
|
||||
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
.pattern=${this.selector.text?.pattern}
|
||||
.validationMessage=${this.selector.text?.validation_message}
|
||||
.autoValidate=${this.selector.text?.pattern !== undefined}
|
||||
@input=${this._handleChange}
|
||||
@change=${this._handleChange}
|
||||
.label=${this.label || ""}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TimezoneSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-timezone-picker";
|
||||
|
||||
@customElement("ha-selector-timezone")
|
||||
export class HaTimezoneSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimezoneSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -23,7 +20,6 @@ export class HaTimezoneSelector extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -895,7 +895,13 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
if (targetEntities.length) {
|
||||
targetEntities = targetEntities.filter((entity) =>
|
||||
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
|
||||
entityMeetsTargetSelector(
|
||||
this.hass.states[entity],
|
||||
targetSelector,
|
||||
undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
target = {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
@@ -52,7 +54,8 @@ export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
|
||||
@customElement("ha-timezone-picker")
|
||||
export class HaTimeZonePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -82,15 +85,14 @@ export class HaTimeZonePicker extends LitElement {
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ??
|
||||
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
|
||||
(this._localize("ui.components.timezone-picker.time_zone") ||
|
||||
"Time zone");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${
|
||||
this.hass?.localize("ui.components.timezone-picker.no_timezones") ||
|
||||
this._localize("ui.components.timezone-picker.no_timezones") ||
|
||||
"No time zones available"
|
||||
}
|
||||
.label=${label}
|
||||
@@ -124,9 +126,10 @@ export class HaTimeZonePicker extends LitElement {
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
|
||||
: html`No time zones found for ${term}`;
|
||||
return (
|
||||
this._localize("ui.components.timezone-picker.no_match", { term }) ||
|
||||
html`No time zones found for ${term}`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@property() public autocomplete?: string;
|
||||
|
||||
/** Regular expression each entry is validated against (HTML `pattern`). */
|
||||
@property() public pattern?: string;
|
||||
|
||||
/** Message shown on an entry when it fails `pattern` validation. */
|
||||
@property({ attribute: "validation-message" })
|
||||
public validationMessage?: string;
|
||||
|
||||
@property({ attribute: "add-label" }) public addLabel?: string;
|
||||
|
||||
@property({ attribute: "remove-label" }) public removeLabel?: string;
|
||||
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
let valid = true;
|
||||
this._inputs?.forEach((input) => {
|
||||
if (!input.reportValidity()) {
|
||||
valid = false;
|
||||
}
|
||||
});
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
|
||||
.type=${this.inputType}
|
||||
.autocomplete=${this.autocomplete}
|
||||
.disabled=${this.disabled}
|
||||
.pattern=${this.pattern}
|
||||
.validationMessage=${this.validationMessage}
|
||||
.autoValidate=${this.pattern !== undefined}
|
||||
dialogInitialFocus=${index}
|
||||
.index=${index}
|
||||
class="flex-auto"
|
||||
|
||||
@@ -140,7 +140,7 @@ export interface HassioAddonSetOptionParams {
|
||||
export const reloadHassioAddons = async (hass: HomeAssistant) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/addons/reload",
|
||||
endpoint: "/store/reload",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,6 +21,21 @@ export interface HttpConfigState {
|
||||
revert_at: string | null;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
+57
-10
@@ -266,6 +266,10 @@ interface EntitySelectorFilter {
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
|
||||
device?: DeviceSelectorFilter;
|
||||
}
|
||||
|
||||
export interface EntitySelectorExtraOption {
|
||||
id: string;
|
||||
primary: string;
|
||||
@@ -281,7 +285,7 @@ export interface EntitySelector {
|
||||
multiple?: boolean;
|
||||
include_entities?: string[];
|
||||
exclude_entities?: string[];
|
||||
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
|
||||
reorder?: boolean;
|
||||
extra_options?: EntitySelectorExtraOption[];
|
||||
} | null;
|
||||
@@ -531,6 +535,10 @@ export interface StringSelector {
|
||||
placeholder?: string;
|
||||
autocomplete?: string;
|
||||
multiple?: true;
|
||||
// Regular expression the value must match (HTML `pattern`); with `multiple`
|
||||
// every entry is validated. `validation_message` is shown when it fails.
|
||||
pattern?: string;
|
||||
validation_message?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -667,7 +675,9 @@ export const expandLabelTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -733,7 +743,9 @@ export const expandAreaTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -756,7 +768,9 @@ export const expandDeviceTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -797,7 +811,9 @@ export const areaMeetsTargetSelector = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -845,14 +861,22 @@ export const deviceMeetsTargetSelector = (
|
||||
export const entityMeetsTargetSelector = (
|
||||
entity: HassEntity | undefined,
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
entitySources?: EntitySources,
|
||||
entities?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"]
|
||||
): boolean => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
|
||||
filterSelectorEntities(filterEntity, entity, entitySources)
|
||||
filterSelectorEntities(
|
||||
filterEntity,
|
||||
entity,
|
||||
entitySources,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -891,9 +915,12 @@ export const filterSelectorDevices = (
|
||||
};
|
||||
|
||||
export const filterSelectorEntities = (
|
||||
filterEntity: EntitySelectorFilter,
|
||||
filterEntity: EntitySelectorEntityFilter,
|
||||
entity: HassEntity,
|
||||
entitySources?: EntitySources
|
||||
entitySources?: EntitySources,
|
||||
entityRegistry?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"],
|
||||
deviceIntegrationLookup?: Record<string, Set<string>>
|
||||
): boolean => {
|
||||
const {
|
||||
domain: filterDomain,
|
||||
@@ -901,6 +928,7 @@ export const filterSelectorEntities = (
|
||||
supported_features: filterSupportedFeature,
|
||||
unit_of_measurement: filterUnitOfMeasurement,
|
||||
integration: filterIntegration,
|
||||
device: filterDevice,
|
||||
} = filterEntity;
|
||||
|
||||
if (filterDomain) {
|
||||
@@ -947,6 +975,24 @@ export const filterSelectorEntities = (
|
||||
}
|
||||
}
|
||||
|
||||
if (filterDevice) {
|
||||
if (!entityRegistry || !devices) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deviceId = entityRegistry[entity.entity_id]?.device_id;
|
||||
if (!deviceId) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (!filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filterIntegration &&
|
||||
entitySources?.[entity.entity_id]?.domain !== filterIntegration
|
||||
@@ -1016,7 +1062,7 @@ export const handleLegacyDeviceSelector = (
|
||||
export const computeCreateDomains = (
|
||||
selector: EntitySelector | TargetSelector
|
||||
): undefined | string[] => {
|
||||
let entityFilters: EntitySelectorFilter[] | undefined;
|
||||
let entityFilters: EntitySelectorEntityFilter[] | undefined;
|
||||
|
||||
if ("target" in selector) {
|
||||
entityFilters = ensureArray(selector.target?.entity);
|
||||
@@ -1034,6 +1080,7 @@ export const computeCreateDomains = (
|
||||
!entityFilter.integration &&
|
||||
!entityFilter.device_class &&
|
||||
!entityFilter.supported_features &&
|
||||
!entityFilter.device &&
|
||||
entityFilter.domain
|
||||
? ensureArray(entityFilter.domain).filter((domain) =>
|
||||
isHelperDomain(domain)
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
|
||||
import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
promoteHttpConfig,
|
||||
saveHttpConfig,
|
||||
} from "../../data/http";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
|
||||
|
||||
const HTTP_FIELDS: (keyof HttpConfig)[] = [
|
||||
"server_port",
|
||||
"server_host",
|
||||
"ssl_certificate",
|
||||
"ssl_key",
|
||||
"ssl_peer_certificate",
|
||||
"ssl_profile",
|
||||
"cors_allowed_origins",
|
||||
"use_x_forwarded_for",
|
||||
"trusted_proxies",
|
||||
"use_x_frame_options",
|
||||
"ip_ban_enabled",
|
||||
"login_attempts_threshold",
|
||||
];
|
||||
|
||||
@customElement("dialog-http-pending-config")
|
||||
export class DialogHttpPendingConfig
|
||||
extends LitElement
|
||||
@@ -57,6 +50,10 @@ export class DialogHttpPendingConfig
|
||||
this._error = undefined;
|
||||
this._reverted = false;
|
||||
this._startCountdown();
|
||||
// The field labels live in the config panel fragment, which is not loaded
|
||||
// yet when this dialog pops up on startup. Load it so the changed-field
|
||||
// names resolve; the dialog re-renders once hass updates.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -114,17 +111,44 @@ export class DialogHttpPendingConfig
|
||||
return [];
|
||||
}
|
||||
const { stable, pending } = this._params.state;
|
||||
return HTTP_FIELDS.filter(
|
||||
return HTTP_CONFIG_FIELDS.filter(
|
||||
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
|
||||
);
|
||||
}
|
||||
|
||||
private _formatValue(key: keyof HttpConfig, value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length
|
||||
? value.join(", ")
|
||||
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
|
||||
}
|
||||
if (key === "ssl_profile") {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`ui.panel.config.network.http.ssl_profile_${value}` as any
|
||||
) || String(value)
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const changes = this._changedFields;
|
||||
const { stable, pending } = this._params.state;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
@@ -187,9 +211,25 @@ export class DialogHttpPendingConfig
|
||||
${changes.map(
|
||||
(key) => html`
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
<span class="field">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${key}` as any
|
||||
)}
|
||||
</span>
|
||||
<span class="values">
|
||||
<span class="old"
|
||||
>${this._formatValue(key, stable[key])}</span
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowRight}
|
||||
style=${styleMap({
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
<span class="new"
|
||||
>${this._formatValue(key, pending![key])}</span
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
@@ -320,15 +360,37 @@ export class DialogHttpPendingConfig
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
color: var(--secondary-text-color);
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.field {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
.values .new {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.values ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
|
||||
@@ -123,6 +123,10 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
|
||||
payload: { event: string };
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
|
||||
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageAppConfiguration extends EMMessage {
|
||||
type: "config_screen/show";
|
||||
}
|
||||
@@ -201,6 +205,7 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMOutgoingMessageBarCodeNotify
|
||||
| EMOutgoingMessageBarCodeScan
|
||||
| EMOutgoingMessageConnectionStatus
|
||||
| EMOutgoingMessageFrontendLoaded
|
||||
| EMOutgoingMessageExoplayerPlayHLS
|
||||
| EMOutgoingMessageExoplayerResize
|
||||
| EMOutgoingMessageExoplayerStop
|
||||
|
||||
@@ -120,6 +120,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
this.render = this.renderHass;
|
||||
this.update = super.update;
|
||||
removeLaunchScreen();
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
super.update(changedProps);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.time_zone"
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
} from "../../../common/string/is_ip_address";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
@@ -10,7 +14,11 @@ import "../../../components/ha-card";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaForm } from "../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
|
||||
import {
|
||||
fetchHttpConfig,
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -24,10 +32,6 @@ const SCHEMA = memoizeOne(
|
||||
required: true,
|
||||
selector: { number: { min: 1, max: 65535, mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "server_host",
|
||||
selector: { text: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
type: "expandable",
|
||||
@@ -81,7 +85,15 @@ const SCHEMA = memoizeOne(
|
||||
},
|
||||
{
|
||||
name: "trusted_proxies",
|
||||
selector: { text: { multiple: true } },
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_network"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -108,6 +120,18 @@ const SCHEMA = memoizeOne(
|
||||
flatten: true,
|
||||
title: localize("ui.panel.config.network.http.sections.advanced"),
|
||||
schema: [
|
||||
{
|
||||
name: "server_host",
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
pattern: IP_ADDRESS_PATTERN,
|
||||
validation_message: localize(
|
||||
"ui.panel.config.network.http.invalid_host"
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cors_allowed_origins",
|
||||
selector: { text: { multiple: true } },
|
||||
@@ -165,6 +189,9 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
const schema = SCHEMA(this.hass.localize);
|
||||
|
||||
const portChanged =
|
||||
!!this._stable && this._config?.server_port !== this._stable.server_port;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -174,6 +201,17 @@ class HaConfigHttpForm extends LitElement {
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.config.network.http.description")}
|
||||
</p>
|
||||
${
|
||||
portChanged
|
||||
? html`
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.http.port_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -318,15 +356,7 @@ class HaConfigHttpForm extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// voluptuous formats errors as "<message> @ data['<field>']".
|
||||
// If a field is identified, mark it inline; otherwise show a card-level
|
||||
// alert.
|
||||
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
|
||||
if (fieldMatch) {
|
||||
this._fieldErrors = { [fieldMatch[1]]: err.message };
|
||||
} else {
|
||||
this._error = err.message;
|
||||
}
|
||||
this._handleSaveError(err);
|
||||
} finally {
|
||||
this._saving = false;
|
||||
}
|
||||
@@ -340,6 +370,34 @@ class HaConfigHttpForm extends LitElement {
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
private _handleSaveError(err: any): void {
|
||||
const rawMessage =
|
||||
(typeof err === "string" ? err : err?.message) ||
|
||||
this.hass.localize("ui.panel.config.network.http.save_error");
|
||||
// Voluptuous formats validation errors as
|
||||
// "<reason> @ data['config']['<field>'][<index>]. Got '<value>'"
|
||||
// Strip the internal data path for display and pick the deepest known
|
||||
// field name so it can also be flagged inline.
|
||||
const message =
|
||||
rawMessage.replace(/\s*@\s*data(\['[^']*'\]|\[\d+\])+/g, "").trim() ||
|
||||
rawMessage;
|
||||
const field = [...rawMessage.matchAll(/\['([^']+)'\]/g)]
|
||||
.map((match) => match[1])
|
||||
.reverse()
|
||||
.find((name) => HTTP_CONFIG_FIELDS.includes(name as keyof HttpConfig)) as
|
||||
keyof HttpConfig | undefined;
|
||||
|
||||
if (field) {
|
||||
// Show a card-level alert too — the field may sit in a collapsed section.
|
||||
this._error = `${this.hass.localize(
|
||||
`ui.panel.config.network.http.fields.${field}` as any
|
||||
)}: ${message}`;
|
||||
this._fieldErrors = { [field]: message };
|
||||
} else {
|
||||
this._error = message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -156,6 +156,7 @@ class MoveDatadiskDialog extends DirtyStateProviderMixin<MoveDatadiskFormState>(
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.storage.datadisk.select_device"
|
||||
)}
|
||||
.value=${this._selectedDevice}
|
||||
@selected=${this._selectDevice}
|
||||
.options=${this._disks.map((disk) => ({
|
||||
value: disk.id,
|
||||
|
||||
@@ -49,8 +49,8 @@ import { resolveEntityIDs } from "../../data/selector";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { fileDownload } from "../../util/file_download";
|
||||
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
|
||||
import { csvSafeString, csvDownload } from "../../util/csv";
|
||||
|
||||
@customElement("ha-panel-history")
|
||||
class HaPanelHistory extends LitElement {
|
||||
@@ -443,8 +443,8 @@ class HaPanelHistory extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const csv: string[] = [""]; // headers will be replaced later.
|
||||
const headers = ["entity_id", "state", "last_changed"];
|
||||
const csv: string[][] = [[]]; // headers will be replaced later.
|
||||
const processedDomainAttributes = new Set<string>();
|
||||
const domainAttributes: Record<string, Record<string, number>> = {
|
||||
climate: {
|
||||
@@ -486,7 +486,7 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
if (entity.statistics) {
|
||||
for (const s of entity.statistics) {
|
||||
csv.push(`${entityId},${s.state},${formatDate(s.last_changed)}\n`);
|
||||
csv.push([entityId, s.state, formatDate(s.last_changed)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,25 +503,22 @@ class HaPanelHistory extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
csv.push(data.join(",") + "\n");
|
||||
csv.push(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const timeline of this._mungedStateHistory.timeline) {
|
||||
const entityId = timeline.entity_id;
|
||||
for (const s of timeline.data) {
|
||||
const safeState = /,|"/.test(s.state)
|
||||
? `"${s.state.replaceAll('"', '""')}"`
|
||||
: s.state;
|
||||
csv.push(`${entityId},${safeState},${formatDate(s.last_changed)}\n`);
|
||||
csv.push([
|
||||
entityId,
|
||||
csvSafeString(s.state),
|
||||
formatDate(s.last_changed),
|
||||
]);
|
||||
}
|
||||
}
|
||||
csv[0] = headers.join(",") + "\n";
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
fileDownload(url, "history.csv");
|
||||
csv[0] = headers;
|
||||
csvDownload(csv, "history.csv");
|
||||
}
|
||||
|
||||
private _suggestCard() {
|
||||
|
||||
@@ -866,7 +866,10 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Baseline-align so a wrapped multi-line value keeps the subject and
|
||||
the trailing time on its first line, while a single-line entry
|
||||
stays aligned with the text. */
|
||||
align-items: baseline;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
@@ -878,9 +881,9 @@ class HaLogbookEntry extends LitElement {
|
||||
.primary-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Wrap long entries onto multiple lines instead of truncating them to
|
||||
a single line with an ellipsis. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.primary > .subject {
|
||||
@@ -905,13 +908,12 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
.value {
|
||||
/* Don't shrink: the subject absorbs all truncation so a short state
|
||||
stays whole. max-width still caps a long one. */
|
||||
/* Don't shrink: the subject absorbs truncation so a short state stays
|
||||
whole. A long value wraps within its max-width instead of being cut
|
||||
off. */
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
private _readyListenerAttached = false;
|
||||
|
||||
public getEntries(): LogbookEntry[] {
|
||||
return this._logbookEntries || [];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!isComponentLoaded(this.hass.config, "logbook")) {
|
||||
return nothing;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { mdiFilterRemove, mdiRefresh } from "@mdi/js";
|
||||
import {
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiFilterRemove,
|
||||
mdiRefresh,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fromUnixTime } from "date-fns";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -18,6 +24,9 @@ import {
|
||||
} from "../../common/url/search-params";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-target-picker";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
@@ -27,6 +36,8 @@ import { resolveEntityIDs } from "../../data/selector";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { csvDownload, csvSafeString } from "../../util/csv";
|
||||
|
||||
interface LogbookState {
|
||||
time: { range: [Date, Date] };
|
||||
@@ -77,12 +88,24 @@ export class HaPanelLogbook extends LitElement {
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.common.reset")}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._refreshLogbook}
|
||||
.path=${mdiRefresh}
|
||||
.label=${this.hass!.localize("ui.common.refresh")}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown-item value="refresh">
|
||||
${this.hass.localize("ui.common.refresh")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiRefresh}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item value="download">
|
||||
${this.hass.localize("ui.panel.logbook.download_data")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="content">
|
||||
<div class="filters">
|
||||
@@ -268,6 +291,67 @@ export class HaPanelLogbook extends LitElement {
|
||||
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
|
||||
}
|
||||
|
||||
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail.item.value;
|
||||
switch (action) {
|
||||
case "download":
|
||||
this._downloadData();
|
||||
break;
|
||||
case "refresh":
|
||||
this._refreshLogbook();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _downloadData() {
|
||||
const data =
|
||||
this.shadowRoot!.querySelector("ha-logbook")?.getEntries() || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.logbook.download_data_error"),
|
||||
text: this.hass.localize("ui.panel.logbook.error_no_data"),
|
||||
warning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"time",
|
||||
"entity_id",
|
||||
"state",
|
||||
"event_type",
|
||||
"context_id",
|
||||
"context_user_id",
|
||||
"context_event_type",
|
||||
"context_domain",
|
||||
"context_service",
|
||||
"context_entity_id",
|
||||
"context_state",
|
||||
"context_source",
|
||||
];
|
||||
const csv: string[][] = [headers];
|
||||
|
||||
for (const d of data) {
|
||||
const time = fromUnixTime(d.when).toISOString();
|
||||
csv.push([
|
||||
time,
|
||||
d.entity_id || "",
|
||||
csvSafeString(d.state),
|
||||
csvSafeString(d.attributes?.event_type),
|
||||
d.context_id || "",
|
||||
d.context_user_id || "",
|
||||
csvSafeString(d.context_event_type),
|
||||
d.context_domain || "",
|
||||
d.context_service || "",
|
||||
d.context_entity_id || "",
|
||||
csvSafeString(d.context_state),
|
||||
d.context_source || "",
|
||||
]);
|
||||
}
|
||||
csvDownload(csv, "activity.csv");
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
LovelaceGridOptions,
|
||||
LovelaceHeaderFooter,
|
||||
} from "../types";
|
||||
import { migrateEntitiesCardConfig } from "./migrate-card-config";
|
||||
import type { EntitiesCardConfig } from "./types";
|
||||
import { haStyleScrollbar } from "../../../resources/styles";
|
||||
|
||||
@@ -49,32 +50,7 @@ export const computeShowHeaderToggle = <
|
||||
return !!config.show_header_toggle;
|
||||
};
|
||||
|
||||
export const migrateEntitiesCardConfig = (
|
||||
config: EntitiesCardConfig
|
||||
): EntitiesCardConfig => {
|
||||
let changed = false;
|
||||
const newEntities = config.entities?.map((e) => {
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: (rest as EntityConfig).time_format ?? format,
|
||||
};
|
||||
});
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
entities: newEntities as (LovelaceRowConfig | string)[],
|
||||
};
|
||||
};
|
||||
export { migrateEntitiesCardConfig };
|
||||
|
||||
@customElement("hui-entities-card")
|
||||
class HuiEntitiesCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@@ -31,35 +31,11 @@ import "../components/hui-timestamp-display";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import "../components/hui-warning-element";
|
||||
import type { LovelaceCard, LovelaceCardEditor } from "../types";
|
||||
import { migrateGlanceCardConfig } from "./migrate-card-config";
|
||||
import type { GlanceCardConfig, GlanceConfigEntity } from "./types";
|
||||
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
|
||||
|
||||
export const migrateGlanceCardConfig = (
|
||||
config: GlanceCardConfig
|
||||
): GlanceCardConfig => {
|
||||
let changed = false;
|
||||
const newEntities = config.entities?.map((e) => {
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: rest.time_format ?? format,
|
||||
};
|
||||
});
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
entities: newEntities as (GlanceConfigEntity | string)[],
|
||||
};
|
||||
};
|
||||
export { migrateGlanceCardConfig };
|
||||
|
||||
@customElement("hui-glance-card")
|
||||
export class HuiGlanceCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
GlanceCardConfig,
|
||||
GlanceConfigEntity,
|
||||
} from "./types";
|
||||
|
||||
export const migrateEntitiesCardConfig = (
|
||||
config: EntitiesCardConfig
|
||||
): EntitiesCardConfig => {
|
||||
let changed = false;
|
||||
const newEntities = config.entities?.map((e) => {
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
}
|
||||
// Custom rows own their config schema and may use `format` with a
|
||||
// different meaning (e.g. custom:multiple-entity-row), so leave it
|
||||
// untouched.
|
||||
if (e.type?.startsWith("custom:")) {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: (rest as EntityConfig).time_format ?? format,
|
||||
};
|
||||
});
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
entities: newEntities as (LovelaceRowConfig | string)[],
|
||||
};
|
||||
};
|
||||
|
||||
export const migrateGlanceCardConfig = (
|
||||
config: GlanceCardConfig
|
||||
): GlanceCardConfig => {
|
||||
let changed = false;
|
||||
const newEntities = config.entities?.map((e) => {
|
||||
if (typeof e !== "object") {
|
||||
return e;
|
||||
}
|
||||
if (!("format" in e)) {
|
||||
return e;
|
||||
}
|
||||
changed = true;
|
||||
const { format, ...rest } = e;
|
||||
return {
|
||||
...rest,
|
||||
time_format: rest.time_format ?? format,
|
||||
};
|
||||
});
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
entities: newEntities as (GlanceConfigEntity | string)[],
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
array,
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { ValueChangedEvent } from "../../../../types";
|
||||
import type { ClockCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
@@ -64,17 +65,13 @@ export class HuiClockCardEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _config?: ClockCardConfig;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
clockStyle: ClockCardConfig["clock_style"],
|
||||
ticks: ClockCardConfig["ticks"],
|
||||
showSeconds: boolean | undefined
|
||||
) =>
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
@@ -114,124 +111,122 @@ export class HuiClockCardEditor
|
||||
ui_clock_date_format: {},
|
||||
},
|
||||
},
|
||||
...(clockStyle === "digital"
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: clockStyle === "analog"
|
||||
? ([
|
||||
{
|
||||
name: "border",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(showSeconds
|
||||
? ([
|
||||
{
|
||||
name: "seconds_motion",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(ticks !== "none"
|
||||
? ([
|
||||
{
|
||||
name: "face_style",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: [
|
||||
"markers",
|
||||
"numbers_upright",
|
||||
"roman",
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: {
|
||||
field: "clock_style",
|
||||
operator: "not_eq",
|
||||
value: "digital",
|
||||
},
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "border",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "seconds_motion",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "show_seconds", operator: "not_eq", value: true },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "face_style",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "ticks", value: "none" },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["markers", "numbers_upright", "roman"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "time_zone", selector: { timezone: {} } },
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
@@ -265,20 +260,14 @@ export class HuiClockCardEditor
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
if (!this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data(this._config)}
|
||||
.schema=${this._schema(
|
||||
this.hass.localize,
|
||||
this._data(this._config).clock_style,
|
||||
this._data(this._config).ticks,
|
||||
this._data(this._config).show_seconds
|
||||
)}
|
||||
.schema=${this._schema(this._localize)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -327,51 +316,43 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "title":
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.title"
|
||||
);
|
||||
return this._localize("ui.panel.lovelace.editor.card.generic.title");
|
||||
case "clock_style":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_style`
|
||||
);
|
||||
case "clock_size":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_size`
|
||||
);
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.clock_size`);
|
||||
case "time_format":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_format`
|
||||
);
|
||||
case "time_zone":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_zone`
|
||||
);
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.time_zone`);
|
||||
case "show_seconds":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.show_seconds`
|
||||
);
|
||||
case "no_background":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.no_background`
|
||||
);
|
||||
case "date_format":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.label`
|
||||
);
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.date.label`);
|
||||
case "border":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.label`
|
||||
);
|
||||
case "ticks":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.label`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.label`
|
||||
);
|
||||
case "face_style":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.label`
|
||||
);
|
||||
default:
|
||||
@@ -384,23 +365,23 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "date_format":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.description`
|
||||
);
|
||||
case "border":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
);
|
||||
case "ticks":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
);
|
||||
case "face_style":
|
||||
return this.hass!.localize(
|
||||
return this._localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -54,6 +54,65 @@ const cardConfigStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
hidden: {
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "min_y_axis", operator: "not_exists" },
|
||||
{ field: "max_y_axis", operator: "not_exists" },
|
||||
],
|
||||
},
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
@customElement("hui-history-graph-card-editor")
|
||||
export class HuiHistoryGraphCardEditor
|
||||
extends LitElement
|
||||
@@ -70,65 +129,6 @@ export class HuiHistoryGraphCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(showFitOption: boolean) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
...(showFitOption
|
||||
? [
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as const
|
||||
);
|
||||
|
||||
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
|
||||
schema: [
|
||||
{ name: "entity", selector: { entity: {} }, required: true },
|
||||
@@ -176,11 +176,6 @@ export class HuiHistoryGraphCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
const schema = this._schema(
|
||||
this._config!.min_y_axis !== undefined ||
|
||||
this._config!.max_y_axis !== undefined
|
||||
);
|
||||
|
||||
const configEntities = this._config.entities
|
||||
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
|
||||
: [];
|
||||
@@ -188,7 +183,7 @@ export class HuiHistoryGraphCardEditor
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${schema}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
@@ -283,9 +278,7 @@ export class HuiHistoryGraphCardEditor
|
||||
) as HistoryGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
switch (schema.name) {
|
||||
case "show_names":
|
||||
case "logarithmic_scale":
|
||||
|
||||
@@ -92,7 +92,6 @@ export class HuiTileCardEditor
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | undefined,
|
||||
hideState: boolean,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
[
|
||||
@@ -144,31 +143,25 @@ export class HuiTileCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
...(!hideState
|
||||
? ([
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "state_content",
|
||||
hidden: { field: "hide_state", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: !showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
@@ -293,12 +286,7 @@ export class HuiTileCardEditor
|
||||
this._config.state_content
|
||||
);
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
entityId,
|
||||
this._config.hide_state ?? false,
|
||||
showTimeFormat
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
|
||||
|
||||
const vertical = this._config.vertical ?? false;
|
||||
|
||||
|
||||
@@ -419,7 +419,6 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: area.icon || "mdi:shape-square-rounded-plus",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-area.no_devices_title"
|
||||
|
||||
@@ -151,7 +151,6 @@ export class HomeOtherDevicesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:check-all",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-other-devices.all_organized_title"
|
||||
|
||||
@@ -483,7 +483,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_title"
|
||||
|
||||
@@ -84,7 +84,6 @@ export class OriginalStatesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_title"
|
||||
|
||||
@@ -1486,6 +1486,7 @@
|
||||
"auto_revert": "Settings will automatically revert in {time}.",
|
||||
"reverted": "Home Assistant reverted the HTTP server settings.",
|
||||
"changes_label": "Changed settings:",
|
||||
"not_set": "Not set",
|
||||
"confirm": "Confirm",
|
||||
"revert": "Revert",
|
||||
"close": "Close",
|
||||
@@ -8679,6 +8680,10 @@
|
||||
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
|
||||
"save": "Save",
|
||||
"save_no_changes": "Nothing changed — no restart needed.",
|
||||
"save_error": "Could not save the HTTP configuration.",
|
||||
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
|
||||
"invalid_host": "Enter a valid IP address.",
|
||||
"invalid_network": "Enter a valid IP address or network.",
|
||||
"save_confirm": {
|
||||
"title": "Restart required",
|
||||
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
|
||||
@@ -8690,7 +8695,7 @@
|
||||
"ssl": "SSL/TLS",
|
||||
"reverse_proxy": "Reverse proxy",
|
||||
"ip_banning": "IP banning",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "More options"
|
||||
},
|
||||
"fields": {
|
||||
"server_port": "Server port",
|
||||
@@ -11566,6 +11571,11 @@
|
||||
"add_card": "Add current view as card",
|
||||
"add_card_error": "Unable to add card",
|
||||
"error_no_data": "You need to select some data sources first."
|
||||
},
|
||||
"logbook": {
|
||||
"download_data": "[%key:ui::panel::history::download_data%]",
|
||||
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
|
||||
"error_no_data": "No activity for the selected target in the selected time range."
|
||||
}
|
||||
},
|
||||
"tips": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fileDownload } from "./file_download";
|
||||
|
||||
export function csvSafeString(s: string | undefined): string {
|
||||
if (!s) return "";
|
||||
return /,|"/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||
}
|
||||
|
||||
export function csvDownload(data: string[][], filename: string) {
|
||||
const csv = data.map((row) => row.join(",").concat("\n"));
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
fileDownload(url, filename);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
IP_ADDRESS_OR_NETWORK_PATTERN,
|
||||
IP_ADDRESS_PATTERN,
|
||||
isIPAddress,
|
||||
isIPAddressV4OrV6,
|
||||
isIPNetwork,
|
||||
isIPv6Address,
|
||||
} from "../../../src/common/string/is_ip_address";
|
||||
|
||||
describe("isIPAddress (IPv4)", () => {
|
||||
it("accepts valid IPv4 addresses", () => {
|
||||
expect(isIPAddress("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddress("0.0.0.0")).toBe(true);
|
||||
expect(isIPAddress("255.255.255.255")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv4 addresses", () => {
|
||||
expect(isIPAddress("256.1.1.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1")).toBe(false);
|
||||
expect(isIPAddress("192.168.1.10/24")).toBe(false);
|
||||
expect(isIPAddress("fe80::1")).toBe(false);
|
||||
expect(isIPAddress("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPv6Address", () => {
|
||||
it("accepts valid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("fe80::85d:e82c:9446:7995")).toBe(true);
|
||||
expect(isIPv6Address("::1")).toBe(true);
|
||||
expect(isIPv6Address("1050:0000:0000:0000:0005:0600:300c:326b")).toBe(true);
|
||||
expect(isIPv6Address("::ffff:192.168.1.1")).toBe(true);
|
||||
});
|
||||
it("rejects invalid IPv6 addresses", () => {
|
||||
expect(isIPv6Address("192.168.1.10")).toBe(false);
|
||||
expect(isIPv6Address("fe80::85d::7995")).toBe(false);
|
||||
expect(isIPv6Address("gggg::1")).toBe(false);
|
||||
expect(isIPv6Address("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPAddressV4OrV6", () => {
|
||||
it("accepts both families", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.10")).toBe(true);
|
||||
expect(isIPAddressV4OrV6("fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(isIPAddressV4OrV6("192.168.1.0/24")).toBe(false);
|
||||
expect(isIPAddressV4OrV6("not-an-ip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIPNetwork (CIDR)", () => {
|
||||
it("accepts valid networks", () => {
|
||||
expect(isIPNetwork("192.168.1.0/24")).toBe(true);
|
||||
expect(isIPNetwork("10.0.0.0/8")).toBe(true);
|
||||
expect(isIPNetwork("172.16.0.0/12")).toBe(true);
|
||||
expect(isIPNetwork("0.0.0.0/0")).toBe(true);
|
||||
expect(isIPNetwork("fd00::/8")).toBe(true);
|
||||
expect(isIPNetwork("fe80::/128")).toBe(true);
|
||||
});
|
||||
it("rejects invalid networks", () => {
|
||||
// Prefix out of range — the reported production error.
|
||||
expect(isIPNetwork("172.30.33.0/24444444")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/33")).toBe(false);
|
||||
expect(isIPNetwork("fd00::/129")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0")).toBe(false);
|
||||
expect(isIPNetwork("192.168.1.0/24/24")).toBe(false);
|
||||
expect(isIPNetwork("not-a-network/24")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The HTML `pattern` attribute is anchored by the browser as `^(?:…)$`.
|
||||
const matchesPattern = (pattern: string, value: string): boolean =>
|
||||
new RegExp(`^(?:${pattern})$`).test(value);
|
||||
|
||||
describe("IP_ADDRESS_PATTERN", () => {
|
||||
it("accepts IPv4 and IPv6 addresses", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.10")).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "fe80::1")).toBe(true);
|
||||
});
|
||||
it("rejects networks and garbage", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.0/24")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "not-an-ip")).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_PATTERN, "256.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IP_ADDRESS_OR_NETWORK_PATTERN", () => {
|
||||
it("accepts addresses and networks", () => {
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.10")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/24")
|
||||
).toBe(true);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "fd00::/8")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
it("rejects out-of-range prefixes and garbage", () => {
|
||||
// The reported production error.
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "172.30.33.0/24444444")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/33")
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "1.1.1.1/233444")
|
||||
).toBe(false);
|
||||
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "not-an-ip")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAvailableClockDatePartSections } from "../../src/components/ha-clock-date-format-picker";
|
||||
|
||||
type TestSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
const section = (id: TestSection, itemIds: string[]) => ({
|
||||
id,
|
||||
title: id,
|
||||
items: itemIds.map((itemId) => ({ id: itemId, primary: itemId })),
|
||||
});
|
||||
|
||||
const ALL_SECTIONS = [
|
||||
section("day", ["day-numeric", "day-2-digit"]),
|
||||
section("month", ["month-numeric", "month-short", "month-long"]),
|
||||
section("year", ["year-numeric", "year-2-digit"]),
|
||||
section("weekday", ["weekday-short", "weekday-long"]),
|
||||
section("separator", [
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
]),
|
||||
];
|
||||
|
||||
describe("getAvailableClockDatePartSections", () => {
|
||||
it("returns every section when no value is set", () => {
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, []);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides a group's section once a value from that group is used", () => {
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
|
||||
"day-numeric",
|
||||
]);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the edited item's own group visible via excludeIndex", () => {
|
||||
const value = ["day-numeric", "month-short"];
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"day",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the edited item's group visible even when a duplicate of that group exists elsewhere", () => {
|
||||
const value = ["day-numeric", "day-2-digit", "month-long"];
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"day",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not crash and keeps normal filtering when excludeIndex is out of range", () => {
|
||||
const value = ["day-numeric", "month-long"];
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 5);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never hides the separator section, even with multiple separators used", () => {
|
||||
const value = [
|
||||
"separator-dash",
|
||||
"separator-slash",
|
||||
"separator-dot",
|
||||
"separator-new-line",
|
||||
];
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an unrecognized token as a separator and does not hide any group", () => {
|
||||
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
|
||||
"not-a-real-part",
|
||||
]);
|
||||
expect(result.map((sectionData) => sectionData.id)).toEqual([
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
|
||||
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
|
||||
|
||||
describe("isFieldHidden", () => {
|
||||
it("shows a field without a hidden condition", () => {
|
||||
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a boolean hidden", () => {
|
||||
expect(isFieldHidden(field(true), {})).toBe(true);
|
||||
expect(isFieldHidden(field(false), {})).toBe(false);
|
||||
});
|
||||
|
||||
describe("operators", () => {
|
||||
it("eq (default) matches equal values", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("not_eq matches different values", () => {
|
||||
const schema = field({ field: "a", operator: "not_eq", value: 1 });
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("in matches membership", () => {
|
||||
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
|
||||
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("not_in matches non-membership", () => {
|
||||
const schema = field({
|
||||
field: "a",
|
||||
operator: "not_in",
|
||||
value: ["x", "y"],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("exists matches a defined non-empty value", () => {
|
||||
const schema = field({ field: "a", operator: "exists" });
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "" })).toBe(false);
|
||||
expect(isFieldHidden(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("not_exists matches a missing or empty value", () => {
|
||||
const schema = field({ field: "a", operator: "not_exists" });
|
||||
expect(isFieldHidden(schema, {})).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinators", () => {
|
||||
it("and requires every condition", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("or requires any condition", () => {
|
||||
const schema = field({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("not negates its conditions", () => {
|
||||
const schema = field({
|
||||
condition: "not",
|
||||
conditions: [{ field: "a", value: 1 }],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("nests combinators", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "b", value: 2 },
|
||||
{ field: "c", value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an array of conditions as AND", () => {
|
||||
const schema = field([
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
]);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing data", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import { filterSelectorEntities } from "../../src/data/selector";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const entity = {
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: {},
|
||||
} as HassEntity;
|
||||
|
||||
const entityRegistry = {
|
||||
"light.living_room": { device_id: "device_1" },
|
||||
} as unknown as HomeAssistant["entities"];
|
||||
|
||||
const devices = {
|
||||
device_1: {
|
||||
id: "device_1",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bulb",
|
||||
model_id: "LCT015",
|
||||
} as DeviceRegistryEntry,
|
||||
} as unknown as HomeAssistant["devices"];
|
||||
|
||||
describe("filterSelectorEntities device filter", () => {
|
||||
it("matches when the nested device manufacturer matches", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when the nested device manufacturer differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Sonos" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches when model and model_id both match", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "LCT015" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when one of model or model_id differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "OTHER" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the device integration via the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "hue" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a device integration that is absent from the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "zha" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when the entity has no underlying device", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
{} as HomeAssistant["entities"],
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("combines device conditions with other entity conditions (AND)", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "light", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "switch", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
migrateEntitiesCardConfig,
|
||||
migrateGlanceCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/migrate-card-config";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
GlanceCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
describe("migrateEntitiesCardConfig", () => {
|
||||
it("migrates the legacy `format` option to `time_format` on native rows", () => {
|
||||
const config = {
|
||||
type: "entities",
|
||||
entities: [{ entity: "sensor.last_changed", format: "relative" }],
|
||||
} as unknown as EntitiesCardConfig;
|
||||
|
||||
const result = migrateEntitiesCardConfig(config);
|
||||
|
||||
expect(result.entities).toEqual([
|
||||
{ entity: "sensor.last_changed", time_format: "relative" },
|
||||
]);
|
||||
// `format` is dropped after migration
|
||||
expect(result.entities[0]).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("leaves custom rows untouched", () => {
|
||||
const customRow = {
|
||||
type: "custom:multiple-entity-row",
|
||||
entity: "sensor.power",
|
||||
format: "precision1",
|
||||
};
|
||||
const config = {
|
||||
type: "entities",
|
||||
entities: [customRow],
|
||||
} as unknown as EntitiesCardConfig;
|
||||
|
||||
const result = migrateEntitiesCardConfig(config);
|
||||
|
||||
// Nothing changed, so the same config reference is returned and the
|
||||
// custom row keeps its own `format` semantics.
|
||||
expect(result).toBe(config);
|
||||
expect(result.entities[0]).toEqual(customRow);
|
||||
});
|
||||
|
||||
it("returns the same config reference when there is nothing to migrate", () => {
|
||||
const config = {
|
||||
type: "entities",
|
||||
entities: [{ entity: "sensor.temperature" }, { entity: "light.kitchen" }],
|
||||
} as EntitiesCardConfig;
|
||||
|
||||
expect(migrateEntitiesCardConfig(config)).toBe(config);
|
||||
});
|
||||
|
||||
it("passes string rows through untouched", () => {
|
||||
const config = {
|
||||
type: "entities",
|
||||
entities: ["sensor.temperature", { entity: "sensor.x", format: "time" }],
|
||||
} as EntitiesCardConfig;
|
||||
|
||||
const result = migrateEntitiesCardConfig(config);
|
||||
|
||||
expect(result.entities[0]).toBe("sensor.temperature");
|
||||
expect(result.entities[1]).toEqual({
|
||||
entity: "sensor.x",
|
||||
time_format: "time",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
|
||||
const config = {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
|
||||
],
|
||||
} as unknown as EntitiesCardConfig;
|
||||
|
||||
const result = migrateEntitiesCardConfig(config);
|
||||
|
||||
expect(result.entities[0]).toEqual({
|
||||
entity: "sensor.x",
|
||||
time_format: "datetime",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateGlanceCardConfig", () => {
|
||||
it("migrates the legacy `format` option to `time_format`", () => {
|
||||
const config = {
|
||||
type: "glance",
|
||||
entities: [{ entity: "sensor.last_changed", format: "relative" }],
|
||||
} as unknown as GlanceCardConfig;
|
||||
|
||||
const result = migrateGlanceCardConfig(config);
|
||||
|
||||
expect(result.entities).toEqual([
|
||||
{ entity: "sensor.last_changed", time_format: "relative" },
|
||||
]);
|
||||
expect(result.entities[0]).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("returns the same config reference when there is nothing to migrate", () => {
|
||||
const config = {
|
||||
type: "glance",
|
||||
entities: ["sensor.temperature", { entity: "light.kitchen" }],
|
||||
} as GlanceCardConfig;
|
||||
|
||||
expect(migrateGlanceCardConfig(config)).toBe(config);
|
||||
});
|
||||
|
||||
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
|
||||
const config = {
|
||||
type: "glance",
|
||||
entities: [
|
||||
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
|
||||
],
|
||||
} as unknown as GlanceCardConfig;
|
||||
|
||||
const result = migrateGlanceCardConfig(config);
|
||||
|
||||
expect(result.entities[0]).toEqual({
|
||||
entity: "sensor.x",
|
||||
time_format: "datetime",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user