Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6573c08f37 | ||
|
|
dcfde89448 | ||
|
|
3016bbdfa2 | ||
|
|
630d7ff82e | ||
|
|
6874781340 | ||
|
|
be214acfd1 |
No files matched your search
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-query-params
|
||||
description: Home Assistant frontend URL query param patterns. Use when keeping page state such as filters, search, sorting or grouping in the URL, adding or reading typed query params, using QueryParamsController or the data table's sync-url and column urlName, building in-app links with a query string, or adding params to My Home Assistant redirects.
|
||||
---
|
||||
|
||||
# HA Frontend Query Params
|
||||
|
||||
Use this skill when implementing or reviewing state kept in the URL's query string, or links that set it. Cross-load `ha-frontend-lit` for the controller and lifecycle work, `ha-frontend-testing` for running tests, and `ha-frontend-review` when reviewing.
|
||||
|
||||
## Typed Params
|
||||
|
||||
`src/common/url/query-params.ts` turns a typed config into values and back:
|
||||
|
||||
- `QueryParamConfig` lists keys by type: `string`, `list` (one comma-separated param, `?label=a,b`), `date` (ISO string) and `boolean` (`{ key, trueValue }`, true only when the param equals `trueValue`).
|
||||
- `QueryParamValues<typeof config>` is the matching partial values type.
|
||||
- `decodeQueryParams(source, config)` reads a `SearchParamsSource` (a `URLSearchParams`, a plain record or a query string). Empty values are skipped.
|
||||
- `createQueryString(values, config)` writes the values, leaving out empty strings, empty lists and false booleans.
|
||||
- `updateQueryString(current, values, config)` replaces only the config's params in an existing query string and keeps every other param exactly as written.
|
||||
- `hasQueryParams(source, config)` tells whether a URL has any of the config's params, which is how a page knows whether a link asked for anything.
|
||||
- `serviceTargetFromQueryParams` and `queryParamsFromServiceTarget` map `HassServiceTarget` keys to list params.
|
||||
|
||||
Give each feature its own `*-query-params.ts` in `src/common/url/` holding its config (`as const satisfies QueryParamConfig`), its values type, and the helpers that turn params into page state and back. Follow the existing ones: `data-table-query-params.ts`, `marketplace-query-params.ts`, `history-logbook-query-params.ts` and `todo-query-params.ts`. Helpers in `src/data/` that build outside links use a local config the same way, as `src/data/integration.ts` and `src/data/system_log_report.ts` do. In new code, prefer these over hand-built `URLSearchParams`, string concatenation or `extractSearchParam` reads from `src/common/url/search-params.ts`.
|
||||
|
||||
## QueryParamsController
|
||||
|
||||
`src/common/controllers/query-params-controller.ts` keeps part of a page's state in the URL. Construct it with the host, the feature's config and options:
|
||||
|
||||
- `enabled()`: whether the host syncs right now. It can change, as on the Marketplace dashboard, which turns it off on the Discover tab.
|
||||
- `navigated()`: called when the page's URL changes while the host is connected (`location-changed` or `popstate`), and when the host reconnects after its first update. Read the URL with `searchParams()`, apply it, then `write()` the state back.
|
||||
|
||||
Methods:
|
||||
|
||||
- `searchParams()` returns the current params, or `undefined` when the controller is disabled or the URL is on another path.
|
||||
- `write(values, debounced?)` updates only the config's params with `replaceCurrentUrl`, so it replaces the current history entry. Pass `debounced` for values that change while typing; the write lands 300 ms after the last one and any later write replaces it.
|
||||
- `flush()` writes a pending debounced write now. The data table calls it when the search box loses focus. Call it before anything that adds its own history entry, such as a dialog.
|
||||
|
||||
The controller binds to `currentPath()` when the host connects and does nothing while the URL is on another path. Navigating away drops a pending write. When a page is reached through a router `defaultPage` (such as `/marketplace` landing on `/marketplace/browse`), check that a link to the bare path still syncs, since the redirect may land after the host connects.
|
||||
|
||||
Read the URL yourself on the first render, after the initial state is set (in `willUpdate` before `hasUpdated`, or `firstUpdated`), and leave later navigations to `navigated()`. `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` show both. When moving a page to the controller, remove its own `location-changed` and `popstate` listeners and any search kept in history state, since the controller replaces them.
|
||||
|
||||
When a router reuses one element for different items, such as one app's tab after another's, key it on the item so the controller binds again to the new path.
|
||||
|
||||
## URL Rules
|
||||
|
||||
- Writes replace the current history entry, so Back leaves the page instead of undoing each change. Push a new entry only for a deliberate navigation, such as picking another log provider.
|
||||
- Debounce search writes. Write sort, group and filter changes straight away.
|
||||
- Leave empty and default values out of the URL, such as an empty search or ascending direction.
|
||||
- A param in the URL wins over stored preferences. A link with none of the page's params keeps what the user picked before; `hasQueryParams` makes that check.
|
||||
- A link with any of the page's params replaces the search and every filter, since a filter left over from before would hide part of what the link shows. Show what the link applied, but don't store it: links often carry someone else's choices, such as a My Home Assistant link made with `m`. Store only what the user changes on the page, and only that setting.
|
||||
- The URL is the whole state. Don't add filters while applying a link; a link that needs one, such as showing disabled devices, says so in its params (`status=disabled`).
|
||||
- A link to a page that is already open must apply the new URL. Handle `navigated()`, not only the first render. The router often keeps a page alive, so this is the common case, not an edge case.
|
||||
- Use one comma-separated param for several values (`?status=new,installed`), which `list` params do.
|
||||
- Use the names other pages already use for the same thing: `search`, `sort`, `direction` and `group` for the table, and `floor`, `area`, `device`, `label`, `config_entry`, `sub_entry` and `assistants` for the shared filters.
|
||||
- A search box's text is always `search`, never `filter` or `q`.
|
||||
- Keep existing param names so existing links and My Home Assistant links keep working. When renaming, read the old name as well for about six months, with a code comment naming the release it can be removed in, and write only the new name. Keep the old name in the config, use it only as a fallback when reading, and leave it out of the values you write, so the next write removes it from the URL.
|
||||
- Pages opt in. Some use params for other things, such as `?domain=` starting the add integration flow on the integrations dashboard, so check what a page already reads before syncing it.
|
||||
- Collapsed groups, column order and hidden columns stay in storage (`@storage`), not the URL.
|
||||
- Link to a page by its main path, not an alias, so a link to an open page lands on the same path and applies.
|
||||
|
||||
## Data Tables
|
||||
|
||||
`hass-tabs-subpage-data-table` with the `sync-url` attribute keeps `search`, `sort`, `direction` and `group` in the URL through `dataTableQueryParamsFromState` and `dataTableQueryStateFromUrl`. A linked search replaces the current one, and sorting or grouping a link leaves out keeps what was picked before. The table fires `search-changed`, `sorting-changed` and `grouping-changed` when a link changes them, with `link` set, so the page shows them without storing them. Use `DataTableSettingsController` (`src/common/controllers/data-table-settings-controller.ts`) for the stored search, sorting and grouping: give it the page's storage keys, bind its `search`, `sorting` and `grouping` to the table, and its `searchChanged`, `sortingChanged` and `groupingChanged` to the table's events. Call `showSearch` for a search the page reads from a link itself. A page the router keeps open shows what the user stored when it's opened again without params, like from the sidebar: the controller and table restore the search, sorting and grouping, and a page with filters restores its stored filters when its URL has no params.
|
||||
|
||||
- Give sortable and groupable columns a short public `urlName` (for example `type`, not `translated_category`). Sorting falls back to the column id without one; grouping is only written for columns that have one, so internal ids don't end up in links.
|
||||
- When a column definition is shared between pages, set its `urlName` where the shared column is defined, so every page uses the same name.
|
||||
- The table reads the URL once, as soon as it has columns. When a column only exists once data has loaded, such as Filename on the dashboards page, turn syncing on only after loading succeeds (`?sync-url=${!this._loading && !this._loadFailed}`), or a link that sorts or groups by that column is dropped.
|
||||
- The page owns its filters. Give it a filter config in its `*-query-params.ts`, a helper that turns filter values into params, and one that turns a link back into the search and filter values. The link helper returns `undefined` when the URL has none of the filter params and none of the table's (`dataTableQueryParamConfig`), so stored choices stay. Use a second controller on the page for them, as `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` do. `browseUrl` and `browseLinkFromUrl` in `src/panels/marketplace/dashboards/dashboard-repositories.ts` show the pair.
|
||||
- Filter components also send the value the page gave them once they have looked up their items, so a filter event isn't always the user's. Store a filter only when it changes what the URL holds, comparing the filters' query strings before and after, as `helpersFilterQueryString` does, and store only that filter.
|
||||
- A memoized filter that also reads async state (anything loaded after the page connects) must take that state as a memoize argument, or a linked filter shows stale results until something else changes.
|
||||
- A filter component that looks up related items asynchronously should drop a lookup whose value has since changed, or two links landing within one lookup apply the older one.
|
||||
|
||||
## Coverage Check
|
||||
|
||||
Before calling a page done:
|
||||
|
||||
1. List every filter, sortable column, groupable column, the search box and any view toggle (such as a tab, raw view or provider). Confirm each one is written to the URL and read back from a link, both on first render and on a page that is already open.
|
||||
2. Search the codebase for in-app links and `navigate()` calls into the page with a query string, and move them to the current param names and the page's main path. `lorawan-connections.ts` and `lorawan-overview.ts` link into `lorawan-devices.ts` this way.
|
||||
|
||||
## My Home Assistant Links
|
||||
|
||||
When a page gains params, add them to its redirect in `getMyRedirects()` in `src/panels/my/ha-panel-my.ts`:
|
||||
|
||||
- List them under `params` as `"string?"`, matching what the page reads. For a data table page that includes `search`, `sort`, `direction` and `group` as well as its filters. Keep the list in a const next to the others, such as `marketplaceTableParams`, which several redirects share.
|
||||
- Point the redirect at the page's main path, the one the page's controller binds to.
|
||||
- Don't add legacy param names.
|
||||
- Add the same params to the redirect's entry in `redirect.json` in `home-assistant/my.home-assistant.io`, in a separate pull request that links the frontend pull request. Merge it only once the frontend pull request is in `dev`.
|
||||
- In the frontend pull request, link the My Home Assistant pull request under "Additional information": `- Link to My Home Assistant pull request: home-assistant/my.home-assistant.io#<n>`.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow `test/common/url/query-params.test.ts`, `test/common/url/data-table-query-params.test.ts` and `test/common/controllers/query-params-controller.test.ts`. For a feature's params file, cover the encode and decode round trip, defaults being left out, and legacy names still being read. Test the helpers, not the page rendering, and only where the logic could break without anyone noticing.
|
||||
@@ -58,7 +58,7 @@ Readiness and invalidation:
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
|
||||
@@ -15,45 +15,38 @@ not UCAndroid > 0
|
||||
# Floors are pinned explicitly (not usage-based) so the support policy is
|
||||
# deliberate and does not drift with global usage statistics, which do not
|
||||
# represent old tablets and wall displays used as Home Assistant dashboards:
|
||||
# - iOS/Safari >= 15.4: where native <dialog> (ha-drawer, popovers,
|
||||
# wa-dialog) landed. Dropped: the iOS 12 devices, iPad Air 1 / mini 2 /
|
||||
# mini 3, iPhone 5s / 6 / 6 Plus and iPod touch 6. Nothing ends between
|
||||
# iOS 12 and 15.8, so no other device is affected. On the Mac, Safari 15.4
|
||||
# runs on macOS 10.15 Catalina and later. The Chrome floor, not Safari,
|
||||
# decides what the legacy build has to transpile.
|
||||
# - Chrome >= 67: the first Chromium with BigInt, which Babel cannot
|
||||
# transpile. The boundary on Fire tablets is the Amazon WebView build, not
|
||||
# the Fire OS major: WebView 70+ (Fire OS 5.6.4+ on the 2017 models, Fire
|
||||
# OS 6.3.1.2+, Fire OS 7) keeps working, WebView 59 (Fire OS 5 on the 2015
|
||||
# and 2016 models, Fire OS 6.3.0.1) is dropped. Also covers the Chromecast
|
||||
# 1st gen (frozen at Chrome 70, the next hard ceiling) and old kiosk
|
||||
# webviews above it.
|
||||
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
|
||||
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
|
||||
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
|
||||
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
|
||||
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
|
||||
# also covers old kiosk browsers and no-longer-updating webviews above it.
|
||||
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
|
||||
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
|
||||
# enterprise installs, whose engines can run the legacy build fine.
|
||||
# - Firefox >= 98: where native <dialog> landed; otherwise a zero-cost floor,
|
||||
# not a chased population — pinning it adds no babel transforms, core-js
|
||||
# modules, or Lightning CSS prefixes to the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
|
||||
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
|
||||
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
|
||||
# If Firefox ever becomes the pin forcing extra output, raise it first.
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets (9.2 is Chromium 67)
|
||||
Chrome >= 67
|
||||
ChromeAndroid >= 67
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
|
||||
Chrome >= 59
|
||||
ChromeAndroid >= 59
|
||||
Edge >= 79
|
||||
Firefox >= 98
|
||||
FirefoxAndroid >= 98
|
||||
iOS >= 15.4
|
||||
Safari >= 15.4
|
||||
Firefox >= 94
|
||||
FirefoxAndroid >= 94
|
||||
iOS >= 12
|
||||
Safari >= 12
|
||||
Samsung >= 9
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy, restricted to browsers that support service workers
|
||||
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
|
||||
Chrome >= 67 and supports serviceworkers
|
||||
ChromeAndroid >= 67 and supports serviceworkers
|
||||
Chrome >= 59 and supports serviceworkers
|
||||
ChromeAndroid >= 59 and supports serviceworkers
|
||||
Edge >= 79 and supports serviceworkers
|
||||
Firefox >= 98 and supports serviceworkers
|
||||
FirefoxAndroid >= 98 and supports serviceworkers
|
||||
iOS >= 15.4 and supports serviceworkers
|
||||
Safari >= 15.4 and supports serviceworkers
|
||||
Firefox >= 94 and supports serviceworkers
|
||||
FirefoxAndroid >= 94 and supports serviceworkers
|
||||
iOS >= 12 and supports serviceworkers
|
||||
Safari >= 12 and supports serviceworkers
|
||||
Samsung >= 9 and supports serviceworkers
|
||||
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
@@ -34,7 +34,7 @@ runs:
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
|
||||
`for more on creating a great pull request.`;
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await withRetry("comment update", () =>
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
- name: Check entrypoint bundle size budget
|
||||
run: pnpm run check-bundlesize
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
@@ -331,21 +331,21 @@ jobs:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -46,7 +46,6 @@ Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching
|
||||
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-query-params`: typed URL query params, `QueryParamsController`, data table URL sync, in-app links, and My Home Assistant redirect params.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
|
||||
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- [View demo of Home Assistant](https://demo.home-assistant.io/)
|
||||
- [More information about Home Assistant](https://home-assistant.io)
|
||||
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
|
||||
## Development
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `pnpm build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ const polyfillSupport = {
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
edge: 18,
|
||||
firefox: 45,
|
||||
ios: 10.3,
|
||||
opera: 48,
|
||||
opera_mobile: 45,
|
||||
safari: 10.1,
|
||||
samsung: 8.0,
|
||||
},
|
||||
"element-toggleattribute": {
|
||||
android: 69,
|
||||
chrome: 69,
|
||||
@@ -56,20 +67,39 @@ const polyfillSupport = {
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
edge: 79,
|
||||
firefox: 69,
|
||||
ios: 13.4,
|
||||
opera: 51,
|
||||
opera_mobile: 47,
|
||||
safari: 13.1,
|
||||
samsung: 9.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Map of instance and static properties to the corresponding polyfill key
|
||||
// and actual module to import
|
||||
// Map of global variables and/or instance and static properties to the
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
attachInternals: {
|
||||
key: "element-internals",
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
toggleAttribute: {
|
||||
key: "element-toggleattribute",
|
||||
module: join(POLYFILL_DIR, "element-toggleattribute.ts"),
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
),
|
||||
},
|
||||
static: {
|
||||
Intl: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 627654,
|
||||
"core": 55147,
|
||||
"authorize": 593862,
|
||||
"onboarding": 709255
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 724225,
|
||||
"core": 157026,
|
||||
"authorize": 693913,
|
||||
"onboarding": 812415
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,8 @@ module.exports.htmlMinifierOptions = {
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 67 / Safari 15) top out at ES2019: optional
|
||||
// catch binding is in, `?.` and `??` (Chrome 80) are not.
|
||||
ecma: latestBuild ? 2020 : 2019,
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -186,6 +185,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
|
||||
path.join(paths.root_dir, "src/resources/polyfills"),
|
||||
...[
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
const polyPath = (...parts) => path.resolve(paths.root_dir, ...parts);
|
||||
@@ -87,24 +83,9 @@ async function copyMapPanel(staticDir) {
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Resolve through barcode-detector, the wasm must match the zxing-wasm JS it bundles
|
||||
const zxingWasm = createRequire(require.resolve("barcode-detector")).resolve(
|
||||
"zxing-wasm/reader/zxing_reader.wasm"
|
||||
);
|
||||
const {
|
||||
ZXING_WASM_SHA256,
|
||||
ZXING_WASM_VERSION,
|
||||
} = require("barcode-detector/pure");
|
||||
const wasm = fs.readFileSync(zxingWasm);
|
||||
if (createHash("sha256").update(wasm).digest("hex") !== ZXING_WASM_SHA256) {
|
||||
throw new Error(
|
||||
`${zxingWasm} does not match the zxing-wasm ${ZXING_WASM_VERSION} bundled in barcode-detector`
|
||||
);
|
||||
}
|
||||
// Versioned file name, see locateFile in ha-qr-scanner
|
||||
fs.outputFileSync(
|
||||
staticPath("js", `zxing_reader-${ZXING_WASM_VERSION}.wasm`),
|
||||
wasm
|
||||
copyFileDir(
|
||||
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
|
||||
staticPath("js")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,7 @@ gulp.task("rspack-watch-app", () => {
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
// Watch the folder via a glob; git replaces en.json on checkout/stash,
|
||||
// which can permanently drop a watch on the file itself
|
||||
path.join(paths.translations_src, "*.json"),
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ const fs = require("fs");
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
const browserslist = require("browserslist");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { RsdoctorRspackPlugin } = require("@rsdoctor/rspack-plugin");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -41,21 +40,6 @@ const TOOLCHAIN_PACKAGES = [
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Not direct dependencies: read the copy the package that loads them uses.
|
||||
const TOOLCHAIN_PACKAGE_PARENTS = {
|
||||
terser: "terser-webpack-plugin",
|
||||
"caniuse-lite": "browserslist",
|
||||
};
|
||||
|
||||
const toolchainVersion = (pkg) => {
|
||||
const parent = TOOLCHAIN_PACKAGE_PARENTS[pkg];
|
||||
const lookup = parent && [
|
||||
path.dirname(require.resolve(`${parent}/package.json`)),
|
||||
];
|
||||
return require(require.resolve(`${pkg}/package.json`, { paths: lookup }))
|
||||
.version;
|
||||
};
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
@@ -79,7 +63,9 @@ const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map((pkg) => `${pkg}@${toolchainVersion(pkg)}`),
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
@@ -229,22 +215,6 @@ const createRspackConfig = ({
|
||||
{
|
||||
test: /\.css$/,
|
||||
type: "asset/source",
|
||||
// Imported stylesheets skip the minifier that compiles css``
|
||||
// templates, so legacy builds compile logical properties and :is()
|
||||
// here
|
||||
use: latestBuild
|
||||
? []
|
||||
: [
|
||||
{
|
||||
loader: "builtin:lightningcss-loader",
|
||||
options: {
|
||||
targets: browserslist.loadConfig({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -410,14 +380,6 @@ const createRspackConfig = ({
|
||||
resolve: {
|
||||
extensions: [".ts", ".js", ".json"],
|
||||
alias: {
|
||||
// @formatjs/bigdecimal uses BigInt (Safari 14, Chrome 67), which
|
||||
// breaks the NumberFormat and PluralRules polyfills
|
||||
...(!latestBuild && {
|
||||
"@formatjs/bigdecimal$": path.join(
|
||||
paths.root_dir,
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
@@ -434,6 +396,8 @@ const createRspackConfig = ({
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js",
|
||||
"@lit-labs/observers/resize-controller":
|
||||
"@lit-labs/observers/resize-controller.js",
|
||||
"@formatjs/intl-durationformat/should-polyfill$":
|
||||
|
||||
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
|
||||
|
||||
export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
const touchSupported =
|
||||
castContext.getDeviceCapabilities()?.touch_input_supported;
|
||||
castContext.getDeviceCapabilities().touch_input_supported;
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
|
||||
let controls: HTMLElement | null;
|
||||
|
||||
const setTouchControlsVisibility = (visible: boolean) => {
|
||||
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (!castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
return;
|
||||
}
|
||||
controls =
|
||||
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
|
||||
clearTimeout(timeOut);
|
||||
timeOut = undefined;
|
||||
}
|
||||
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,45 +142,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise, [
|
||||
{
|
||||
id: "music",
|
||||
url_path: "music",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:music",
|
||||
title: "Music",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "cameras",
|
||||
url_path: "cameras",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:cctv",
|
||||
title: "Cameras",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "garden",
|
||||
url_path: "garden",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:flower",
|
||||
title: "Garden",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "lovelace-advanced",
|
||||
url_path: "lovelace-advanced",
|
||||
require_admin: false,
|
||||
show_in_sidebar: false,
|
||||
icon: "mdi:cog",
|
||||
title: "Advanced",
|
||||
mode: "yaml",
|
||||
filename: "lovelace-advanced.yaml",
|
||||
},
|
||||
]);
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
@@ -210,35 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass, [
|
||||
{
|
||||
label_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
icon: null,
|
||||
color: "red",
|
||||
description: "Everything in the kitchen",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:flower",
|
||||
color: "green",
|
||||
description: null,
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "battery-powered",
|
||||
name: "Battery powered",
|
||||
icon: null,
|
||||
color: "orange",
|
||||
description: "Runs on batteries",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
]);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
hass.mockWS(
|
||||
"blueprint/delete",
|
||||
(msg: { domain: BlueprintDomain; path: string }) => {
|
||||
const blueprints =
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
|
||||
delete blueprints[msg.path];
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lorawanFixtures } from "./lorawan/fixtures";
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
lorawanFixtures,
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
modbusFixtures,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mockLoRaWAN } from "./lorawan/mock";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockLoRaWAN,
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockModbus,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
|
||||
export const TTN_ENTRY = "mock-lorawan-ttn";
|
||||
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_temperature",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Temperature"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_humidity",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Humidity"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_study_carbon_dioxide",
|
||||
"lorawan-study",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Carbon dioxide"
|
||||
),
|
||||
];
|
||||
|
||||
export const lorawanFixtures: ConnectivityFixtures = {
|
||||
components: [
|
||||
"lorawan",
|
||||
"sensecap_lorawan",
|
||||
"chirpstack",
|
||||
"thethingsnetwork_lorawan",
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
TTN_ENTRY,
|
||||
"thethingsnetwork_lorawan",
|
||||
"The Things Network"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
device(
|
||||
"lorawan-greenhouse",
|
||||
"Greenhouse",
|
||||
"Seeed Studio",
|
||||
"S2101",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
|
||||
identifiers: [
|
||||
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
|
||||
],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"lorawan-study",
|
||||
"Study air quality",
|
||||
"Seeed Studio",
|
||||
"S2103",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
|
||||
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
|
||||
}
|
||||
),
|
||||
],
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.lorawan_greenhouse_temperature": {
|
||||
entity_id: "sensor.lorawan_greenhouse_temperature",
|
||||
state: "24.3",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_greenhouse_humidity": {
|
||||
entity_id: "sensor.lorawan_greenhouse_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_study_carbon_dioxide": {
|
||||
entity_id: "sensor.lorawan_study_carbon_dioxide",
|
||||
state: "652",
|
||||
attributes: {
|
||||
friendly_name: "Study carbon dioxide",
|
||||
device_class: "carbon_dioxide",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "ppm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
|
||||
|
||||
const devices = (): LoRaWANDevice[] => {
|
||||
const base: LoRaWANDevice = {
|
||||
dev_eui: "0000000000000001",
|
||||
name: "Greenhouse",
|
||||
manufacturer: "Seeed Studio",
|
||||
model: "S2101",
|
||||
application_id: "home-sensors",
|
||||
device_id: "lorawan-greenhouse",
|
||||
connection: {
|
||||
entry_id: CHIRPSTACK_ENTRY,
|
||||
domain: "chirpstack",
|
||||
title: "Home ChirpStack",
|
||||
configuration_url: "https://www.chirpstack.io/",
|
||||
},
|
||||
support: "supported",
|
||||
last_uplink: minutesAgo(2),
|
||||
last_join: minutesAgo(2400),
|
||||
last_status: minutesAgo(64),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
|
||||
battery_level: 87,
|
||||
external_power_source: false,
|
||||
downlink_margin: 12,
|
||||
};
|
||||
return [
|
||||
base,
|
||||
{
|
||||
...base,
|
||||
dev_eui: "0000000000000002",
|
||||
name: "Study air quality",
|
||||
model: "S2103",
|
||||
device_id: "lorawan-study",
|
||||
application_id: "demo-home",
|
||||
connection: {
|
||||
entry_id: TTN_ENTRY,
|
||||
domain: "thethingsnetwork_lorawan",
|
||||
title: "The Things Network",
|
||||
configuration_url: "https://eu1.cloud.thethings.network/console",
|
||||
},
|
||||
receptions: [
|
||||
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
|
||||
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
|
||||
],
|
||||
battery_level: 64,
|
||||
last_uplink: minutesAgo(4),
|
||||
last_status: minutesAgo(140),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "24E124128E123456",
|
||||
name: "Irrigation controller",
|
||||
manufacturer: "Milesight",
|
||||
model: "UC511",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 93,
|
||||
last_uplink: minutesAgo(7),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "A84041FFFF123456",
|
||||
name: "Garage power meter",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
device_id: null,
|
||||
support: "missing_identity",
|
||||
battery_level: null,
|
||||
external_power_source: true,
|
||||
last_uplink: minutesAgo(1),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500199",
|
||||
name: "New soil sensor",
|
||||
model: "S2105",
|
||||
device_id: null,
|
||||
support: "unsupported_model",
|
||||
last_uplink: null,
|
||||
last_join: null,
|
||||
last_status: null,
|
||||
receptions: [],
|
||||
battery_level: null,
|
||||
external_power_source: null,
|
||||
downlink_margin: null,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500166",
|
||||
name: "Garden shed",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 18,
|
||||
last_uplink: minutesAgo(180),
|
||||
last_status: minutesAgo(600),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
|
||||
const deviceList = devices();
|
||||
const snapshot = {
|
||||
devices: deviceList,
|
||||
connections: [
|
||||
...new Map(
|
||||
deviceList.map((device) => [
|
||||
device.connection.entry_id,
|
||||
device.connection,
|
||||
])
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
hass.mockWS("lorawan/devices/list", () => snapshot);
|
||||
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(snapshot))
|
||||
);
|
||||
};
|
||||
@@ -190,39 +190,6 @@ export const removeEntityRegistryEntry = (
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const removeLabelFromEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
labelId: string
|
||||
) => {
|
||||
const withoutLabel = (labels: string[]) =>
|
||||
labels.filter((id) => id !== labelId);
|
||||
for (const [key, entry] of retiredEntries) {
|
||||
if (entry.labels.includes(labelId)) {
|
||||
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
|
||||
}
|
||||
}
|
||||
if (!entries.some((entry) => entry.labels.includes(labelId))) {
|
||||
return;
|
||||
}
|
||||
entries = entries.map((entry) =>
|
||||
entry.labels.includes(labelId)
|
||||
? { ...entry, labels: withoutLabel(entry.labels) }
|
||||
: entry
|
||||
);
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of entries) {
|
||||
const entity = entities[entry.entity_id];
|
||||
if (entity?.labels.includes(labelId)) {
|
||||
entities[entry.entity_id] = {
|
||||
...entity,
|
||||
labels: withoutLabel(entity.labels),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { removeLabelFromEntityRegistry } from "./entity_registry";
|
||||
|
||||
let labels: LabelRegistryEntry[] = [];
|
||||
|
||||
@@ -10,22 +9,6 @@ export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => {
|
||||
labels = [...data];
|
||||
hass.mockWS("config/label_registry/list", () =>
|
||||
labels.map((label) => ({ ...label }))
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/label_registry/delete",
|
||||
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
|
||||
const index = labels.findIndex(
|
||||
(label) => label.label_id === msg.label_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Label ${msg.label_id} not found`);
|
||||
}
|
||||
labels.splice(index, 1);
|
||||
removeLabelFromEntityRegistry(currentHass, msg.label_id);
|
||||
hass.mockEvent("label_registry_updated");
|
||||
}
|
||||
);
|
||||
labels = data;
|
||||
hass.mockWS("config/label_registry/list", () => data);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mockMap } from "./map";
|
||||
|
||||
let dashboards: LovelaceDashboard[] = [];
|
||||
|
||||
export const mockLovelace = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise: Promise<LocalizeFunc>,
|
||||
data: LovelaceDashboard[] = []
|
||||
localizePromise: Promise<LocalizeFunc>
|
||||
) => {
|
||||
dashboards = data;
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
mockMap(hass);
|
||||
@@ -39,16 +34,7 @@ export const mockLovelace = (
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () =>
|
||||
dashboards.map((dashboard) => ({ ...dashboard }))
|
||||
);
|
||||
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
|
||||
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
|
||||
}
|
||||
dashboards.splice(index, 1);
|
||||
});
|
||||
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
|
||||
};
|
||||
|
||||
customElements.whenDefined("hui-root").then(() => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
constructFrom,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
} from "date-fns";
|
||||
@@ -15,18 +13,6 @@ import type {
|
||||
} from "../../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const getFirstDate = (
|
||||
start: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): Date => {
|
||||
if (period === "day" || period === "month") {
|
||||
return constructFrom(start, start);
|
||||
}
|
||||
// Core returns UTC-aligned buckets whose start is at or after the request.
|
||||
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
|
||||
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
|
||||
};
|
||||
|
||||
const getNextDate = (
|
||||
currentDate: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
@@ -76,7 +63,8 @@ const generateSumStatistics = (
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
@@ -110,7 +98,8 @@ const generateCurvedStatistics = (
|
||||
metered: boolean
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const hours = differenceInHours(end, start) - 1;
|
||||
let i = 0;
|
||||
@@ -173,9 +162,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 10);
|
||||
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
|
||||
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const morningFinalVal = morningLow.length
|
||||
? morningLow[morningLow.length - 1].sum!
|
||||
: 0;
|
||||
@@ -210,8 +199,8 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 9);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const highTarif = generateSumStatistics(
|
||||
morningEnd,
|
||||
eveningStart,
|
||||
@@ -250,12 +239,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 9);
|
||||
const productionEnd = addHours(start, 21);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -294,12 +280,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 7);
|
||||
const productionEnd = addHours(start, 23);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -346,9 +329,8 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/statistics_during_period",
|
||||
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
|
||||
// Calendar buckets follow the server timezone, independent of the browser.
|
||||
const start = new TZDate(start_time, hass.config.time_zone);
|
||||
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
|
||||
const start = new Date(start_time);
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
|
||||
@@ -37,7 +37,6 @@ export default [
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-target-picker-exclusions",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
@@ -127,7 +126,6 @@ export default [
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"calendar-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
@@ -169,10 +167,6 @@ export default [
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "Time and schedules",
|
||||
pages: ["calendar"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { CalendarEntityFeature } from "../../../src/data/calendar";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Sample calendars with events relative to today, so demos always show
|
||||
// something in the current week and month.
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const day = (offset: number): Date => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offset);
|
||||
return date;
|
||||
};
|
||||
|
||||
const isoDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
|
||||
const at = (offset: number, hours: number, minutes = 0) => {
|
||||
const date = day(offset);
|
||||
date.setHours(hours, minutes);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const allDay = (offset: number, days = 1) => ({
|
||||
start: isoDate(day(offset)),
|
||||
end: isoDate(day(offset + days)),
|
||||
all_day: true,
|
||||
});
|
||||
|
||||
const FAMILY_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
|
||||
{
|
||||
summary: "Dentist",
|
||||
start: at(0, 15, 30),
|
||||
end: at(0, 16, 15),
|
||||
location: "Main Street 12",
|
||||
},
|
||||
{ summary: "Grandma's birthday", ...allDay(1) },
|
||||
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
|
||||
{
|
||||
summary: "Dinner with friends",
|
||||
start: at(3, 19),
|
||||
end: at(3, 22),
|
||||
status: "tentative",
|
||||
},
|
||||
{ summary: "Summer cabin", ...allDay(5, 3) },
|
||||
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
|
||||
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
|
||||
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
|
||||
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
|
||||
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
|
||||
];
|
||||
|
||||
const WORK_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
|
||||
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
|
||||
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
|
||||
{ summary: "Conference", ...allDay(8, 2) },
|
||||
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
|
||||
];
|
||||
|
||||
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
|
||||
"calendar.family": FAMILY_EVENTS,
|
||||
"calendar.work": WORK_EVENTS,
|
||||
"calendar.empty": [],
|
||||
// A calendar whose integration fails to return events
|
||||
"calendar.holidays": null,
|
||||
};
|
||||
|
||||
export const CALENDAR_ENTITIES = [
|
||||
{
|
||||
entity_id: "calendar.family",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Family",
|
||||
message: "School run",
|
||||
all_day: false,
|
||||
start_time: at(0, 8),
|
||||
end_time: at(0, 8, 30),
|
||||
location: "",
|
||||
description: "",
|
||||
supported_features:
|
||||
CalendarEntityFeature.CREATE_EVENT +
|
||||
CalendarEntityFeature.DELETE_EVENT +
|
||||
CalendarEntityFeature.UPDATE_EVENT,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "calendar.work",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Work",
|
||||
message: "Stand-up",
|
||||
all_day: false,
|
||||
start_time: at(0, 9),
|
||||
end_time: at(0, 9, 15),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "calendar.empty",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Empty calendar" },
|
||||
},
|
||||
{
|
||||
entity_id: "calendar.holidays",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Holidays (failing)" },
|
||||
},
|
||||
];
|
||||
|
||||
const registryEntry = (
|
||||
entityId: string,
|
||||
color?: string
|
||||
): EntityRegistryEntry => ({
|
||||
id: entityId,
|
||||
entity_id: entityId,
|
||||
name: null,
|
||||
icon: null,
|
||||
platform: "local_calendar",
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
labels: [],
|
||||
disabled_by: null,
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
unique_id: entityId,
|
||||
options: color ? { calendar: { color } } : null,
|
||||
categories: {},
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
});
|
||||
|
||||
export const mockCalendars = (hass: MockHomeAssistant) => {
|
||||
mockEntityRegistry(hass, [
|
||||
registryEntry("calendar.family"),
|
||||
// Shows a color picked in the entity settings
|
||||
registryEntry("calendar.work", "deep-orange"),
|
||||
registryEntry("calendar.empty"),
|
||||
registryEntry("calendar.holidays"),
|
||||
]);
|
||||
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
|
||||
const start = new Date(msg.start).getTime();
|
||||
const end = new Date(msg.end).getTime();
|
||||
const events = EVENTS[msg.entity_id];
|
||||
onChange?.({
|
||||
events:
|
||||
events?.filter(
|
||||
(event) =>
|
||||
new Date(event.end as string).getTime() > start &&
|
||||
new Date(event.start as string).getTime() < end
|
||||
) ?? null,
|
||||
});
|
||||
return () => undefined;
|
||||
});
|
||||
// Changes made in the event dialogs are accepted but not stored
|
||||
hass.mockWS("calendar/event/create", () => null);
|
||||
hass.mockWS("calendar/event/update", () => null);
|
||||
hass.mockWS("calendar/event/delete", () => null);
|
||||
};
|
||||
@@ -35,12 +35,6 @@ const ENTITIES = [
|
||||
|
||||
const triggers = [
|
||||
{ trigger: "state", entity_id: "light.kitchen", from: "off", to: "on" },
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "light.kitchen",
|
||||
not_from: ["unavailable", "unknown"],
|
||||
not_to: "off",
|
||||
},
|
||||
{ trigger: "mqtt" },
|
||||
{
|
||||
trigger: "geo_location",
|
||||
|
||||
@@ -33,15 +33,7 @@ import "../../components/demo-black-white-row";
|
||||
const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
|
||||
{
|
||||
name: "State",
|
||||
triggers: [
|
||||
{ ...HaStateTrigger.defaultConfig },
|
||||
{
|
||||
trigger: "state",
|
||||
entity_id: "light.kitchen",
|
||||
not_from: ["unavailable", "unknown"],
|
||||
to: "on",
|
||||
},
|
||||
],
|
||||
triggers: [{ ...HaStateTrigger.defaultConfig }],
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
title: Target picker exclusions
|
||||
subtitle: How the target picker shows and edits the exclude of a target
|
||||
---
|
||||
|
||||
A target can leave things out again with `exclude`, which has the same shape as
|
||||
the target itself. Excludes are global: an exclusion takes its entities out of
|
||||
every included target, not only one.
|
||||
|
||||
The target selector shows exclusions as follows:
|
||||
|
||||
- **Exclude** sits next to **Add target**. It offers only what overlaps with the
|
||||
included targets and hides what is already excluded. Child devices of an
|
||||
included device can still be excluded on their own.
|
||||
- Each included target lists, muted, the exclusions that take entities out of
|
||||
it, with how many they remove. Its count says how many are excluded.
|
||||
- An exclusion that takes nothing out of any target, like one of a deleted
|
||||
area, is listed under **Exclusions with no effect**, where it can be removed.
|
||||
- The target details show which entities are excluded and by what, and let
|
||||
you tick or untick what the target holds.
|
||||
|
||||
Only the target selector offers **Exclude**. Other users of the target picker
|
||||
set `allow-exclude` once they handle `exclude`.
|
||||
@@ -1,302 +0,0 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { ensureArray } from "../../../../src/common/array/ensure-array";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { Selector } from "../../../../src/data/selector";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
} from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
|
||||
const device = (
|
||||
id: string,
|
||||
name: string,
|
||||
areaId: string,
|
||||
parentDeviceId: string | null = null
|
||||
): DeviceRegistryEntry => ({
|
||||
area_id: areaId,
|
||||
configuration_url: null,
|
||||
config_entries: [],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id,
|
||||
identifiers: [["demo", id] as [string, string]],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name,
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: parentDeviceId,
|
||||
});
|
||||
|
||||
// A power strip in the backyard with two outlets as child devices, and a lamp
|
||||
// in the bedroom.
|
||||
const DEVICES: DeviceRegistryEntry[] = [
|
||||
device("power_strip", "Power strip", "backyard"),
|
||||
device("outlet_1", "Outlet 1", "backyard", "power_strip"),
|
||||
device("outlet_2", "Outlet 2", "backyard", "power_strip"),
|
||||
device("bedroom_lamp", "Bedroom lamp", "bedroom"),
|
||||
];
|
||||
|
||||
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
|
||||
"switch.outlet_1": {
|
||||
entity_id: "switch.outlet_1",
|
||||
name: "Outlet 1",
|
||||
device_id: "outlet_1",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"switch.outlet_2": {
|
||||
entity_id: "switch.outlet_2",
|
||||
name: "Outlet 2",
|
||||
device_id: "outlet_2",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"light.bedroom_lamp": {
|
||||
entity_id: "light.bedroom_lamp",
|
||||
name: "Bedroom lamp",
|
||||
device_id: "bedroom_lamp",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"light.bedside": {
|
||||
entity_id: "light.bedside",
|
||||
name: "Bedside light",
|
||||
area_id: "bedroom",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"light.night_light": {
|
||||
entity_id: "light.night_light",
|
||||
name: "Night light",
|
||||
area_id: "bedroom",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
};
|
||||
|
||||
const ENTITIES = Object.values(ENTITY_REGISTRY).map((entity) => ({
|
||||
entity_id: entity.entity_id,
|
||||
state: entity.entity_id.startsWith("light.") ? "on" : "off",
|
||||
attributes: { friendly_name: entity.name },
|
||||
}));
|
||||
|
||||
const area = (areaId: string, name: string): AreaRegistryEntry => ({
|
||||
area_id: areaId,
|
||||
floor_id: null,
|
||||
name,
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
});
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
area("backyard", "Backyard"),
|
||||
area("bedroom", "Bedroom"),
|
||||
];
|
||||
|
||||
// Resolves a target against the demo registries, like core's
|
||||
// extract_from_target does: a device includes its child devices, and an area
|
||||
// its devices and entities.
|
||||
const extractFromTarget = ({ target }: { target: HassServiceTarget }) => {
|
||||
const ids = (key: keyof HassServiceTarget): string[] =>
|
||||
target[key] ? ensureArray(target[key]) : [];
|
||||
const areaIds = ids("area_id").filter((id) =>
|
||||
AREAS.some((a) => a.area_id === id)
|
||||
);
|
||||
const deviceIds = new Set([
|
||||
...ids("device_id").filter((id) => DEVICES.some((d) => d.id === id)),
|
||||
...DEVICES.filter((d) => d.area_id && areaIds.includes(d.area_id)).map(
|
||||
(d) => d.id
|
||||
),
|
||||
]);
|
||||
DEVICES.forEach((d) => {
|
||||
if (d.parent_device_id && deviceIds.has(d.parent_device_id)) {
|
||||
deviceIds.add(d.id);
|
||||
}
|
||||
});
|
||||
const entityIds = new Set(ids("entity_id"));
|
||||
Object.values(ENTITY_REGISTRY).forEach((entity) => {
|
||||
if (
|
||||
(entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.includes(entity.area_id))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
});
|
||||
return {
|
||||
referenced_entities: [...entityIds],
|
||||
referenced_devices: [...deviceIds],
|
||||
referenced_areas: areaIds,
|
||||
missing_areas: [],
|
||||
missing_devices: [],
|
||||
missing_floors: [],
|
||||
missing_labels: [],
|
||||
};
|
||||
};
|
||||
|
||||
interface Sample {
|
||||
name: string;
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
{
|
||||
name: "Target with exclusions",
|
||||
description:
|
||||
"Each target lists what is excluded from it. An exclusion of an area that no longer exists has no effect and is listed apart.",
|
||||
selector: { target: {} },
|
||||
value: {
|
||||
area_id: ["bedroom", "backyard"],
|
||||
exclude: {
|
||||
entity_id: ["light.night_light"],
|
||||
device_id: ["outlet_2"],
|
||||
area_id: ["deleted_area"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Target without exclusions",
|
||||
description:
|
||||
"Exclude offers what the power strip includes, so its outlets can be excluded on their own.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["power_strip"] },
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-target-picker-exclusions")
|
||||
class DemoHaTargetPickerExclusions
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() public hass!: HomeAssistant;
|
||||
|
||||
@state() private _values = SAMPLES.map((sample) => sample.value);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
mockEntityRegistry(hass);
|
||||
mockDeviceRegistry(hass, DEVICES);
|
||||
mockConfigEntries(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
// Set the areas and entity registry directly, typed against the real
|
||||
// registry types, instead of the demo stubs whose types differ.
|
||||
const areas: Record<string, AreaRegistryEntry> = {};
|
||||
AREAS.forEach((a) => {
|
||||
areas[a.area_id] = a;
|
||||
});
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
|
||||
hass.mockWS("extract_from_target", extractFromTarget);
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
e.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${SAMPLES.map(
|
||||
(sample, idx) => html`
|
||||
<demo-black-white-row .title=${sample.name}>
|
||||
${["light", "dark"].map(
|
||||
(slot) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
</demo-black-white-row>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleValueChanged(ev) {
|
||||
const idx = ev.target.sampleIdx;
|
||||
this._values = this._values.map((value, i) =>
|
||||
i === idx ? ev.detail.value : value
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-settings-row {
|
||||
--settings-row-content-width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-target-picker-exclusions": DemoHaTargetPickerExclusions;
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
|
||||
|
||||
## Developers
|
||||
|
||||
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
|
||||
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
title: Calendar card
|
||||
---
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { CalendarCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import "../../components/demo-cards";
|
||||
import { CALENDAR_ENTITIES, mockCalendars } from "../../data/calendar";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Month view",
|
||||
config: {
|
||||
type: "calendar",
|
||||
entities: ["calendar.family", "calendar.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "List view with title",
|
||||
config: {
|
||||
type: "calendar",
|
||||
title: "This week",
|
||||
initial_view: "listWeek",
|
||||
entities: ["calendar.family", "calendar.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Day view",
|
||||
config: {
|
||||
type: "calendar",
|
||||
initial_view: "dayGridDay",
|
||||
entities: ["calendar.family", "calendar.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Add event button",
|
||||
config: {
|
||||
type: "calendar",
|
||||
show_add_event: true,
|
||||
entities: ["calendar.family"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Failing calendar",
|
||||
config: {
|
||||
type: "calendar",
|
||||
initial_view: "listWeek",
|
||||
entities: ["calendar.family", "calendar.holidays"],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<CalendarCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-calendar-card")
|
||||
class DemoCalendarCard extends LitElement {
|
||||
@state() private hass?: HomeAssistant;
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
// Opens the event detail and editor dialogs of the calendar
|
||||
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
e.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<demo-cards
|
||||
.hass=${this.hass}
|
||||
.configs=${CONFIGS}
|
||||
></demo-cards>`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Provided on this element, so the dialogs it opens get the mocks too
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("lovelace", "en");
|
||||
mockCalendars(hass);
|
||||
hass.addEntities(CALENDAR_ENTITIES);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-lovelace-calendar-card": DemoCalendarCard;
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ const CONFIGS = [
|
||||
|
||||
### ha-alert
|
||||
|
||||
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
|
||||
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
|
||||
|
||||
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
|
||||
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
|
||||
|
||||
@@ -109,29 +109,6 @@ const disabledFailedUnloadEntry = createConfigEntry(
|
||||
}
|
||||
);
|
||||
|
||||
const brandEntries: ConfigEntryExtended[] = (
|
||||
[
|
||||
["hue", "Philips Hue", "loaded"],
|
||||
["hue", "Philips Hue", "setup_error"],
|
||||
["mqtt", "MQTT", "setup_retry"],
|
||||
["zha", "Zigbee Home Automation", "not_loaded"],
|
||||
["sonos", "Sonos", "setup_error"],
|
||||
["shelly", "Shelly", "failed_unload"],
|
||||
["spotify", "Spotify", "setup_retry"],
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
createConfigEntry(`${name} ${entryState}`, {
|
||||
domain,
|
||||
localized_domain_name: name,
|
||||
state: entryState,
|
||||
})
|
||||
);
|
||||
|
||||
const configFlows: DataEntryFlowProgressExtended[] = [
|
||||
{
|
||||
flow_id: "adbb401329d8439ebb78ef29837826a8",
|
||||
@@ -348,21 +325,6 @@ export class DemoIntegrationCard extends LitElement {
|
||||
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
|
||||
></ha-integration-card>
|
||||
</div>
|
||||
<div class="container">
|
||||
${brandEntries.map(
|
||||
(entry) => html`
|
||||
<ha-integration-card
|
||||
.domain=${entry.domain}
|
||||
.items=${[entry]}
|
||||
.manifest=${createManifest(
|
||||
this.isCustomIntegration,
|
||||
this.isCloud
|
||||
)}
|
||||
.entityRegistryEntries=${createEntityRegistryEntries(entry)}
|
||||
></ha-integration-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
title: Calendar
|
||||
---
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/dialogs/more-info/more-info-content";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-more-infos";
|
||||
import { CALENDAR_ENTITIES, mockCalendars } from "../../data/calendar";
|
||||
|
||||
const ENTITIES = ["calendar.family", "calendar.empty", "calendar.holidays"];
|
||||
|
||||
@customElement("demo-more-info-calendar")
|
||||
class DemoMoreInfoCalendar extends LitElement {
|
||||
@property({ attribute: false }) public hass!: MockHomeAssistant;
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
// Opens the event detail and editor dialogs of the calendar
|
||||
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
e.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<demo-more-infos
|
||||
.hass=${this.hass}
|
||||
.entities=${ENTITIES}
|
||||
></demo-more-infos>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Provided on this element, so the dialogs it opens get the mocks too
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
mockCalendars(hass);
|
||||
hass.addEntities(CALENDAR_ENTITIES);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-more-info-calendar": DemoMoreInfoCalendar;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
|
||||
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
|
||||
- use the dev container
|
||||
- start the dev server with following optional env vars:
|
||||
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
|
||||
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
|
||||
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
|
||||
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
|
||||
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
|
||||
|
||||
@@ -21,13 +21,10 @@ import {
|
||||
getObserverLogs,
|
||||
downloadUrl as observerLogsDownloadUrl,
|
||||
} from "../data/observer";
|
||||
import {
|
||||
getSupervisorLogs,
|
||||
getSupervisorLogsFollow,
|
||||
isSupervisorLogError,
|
||||
} from "../data/supervisor";
|
||||
import { getSupervisorLogs, getSupervisorLogsFollow } from "../data/supervisor";
|
||||
import { ASSUME_CORE_START_SECONDS } from "../ha-landing-page";
|
||||
|
||||
const ERROR_CHECK = /^[\d\s-:]+(ERROR|CRITICAL)(.*)/gm;
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"landing-page-error": undefined;
|
||||
@@ -64,8 +61,6 @@ class LandingPageLogs extends LitElement {
|
||||
|
||||
@state() private _logLinesCount = 0;
|
||||
|
||||
private _errorFound = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="actions">
|
||||
@@ -165,28 +160,26 @@ class LandingPageLogs extends LitElement {
|
||||
this._logLinesCount = 0;
|
||||
}
|
||||
|
||||
const showError = ERROR_CHECK.test(logs);
|
||||
|
||||
const scrolledToBottom = this._scrolledToBottomController.value;
|
||||
const lines = `${tempLogLine}${logs}`
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
// handle edge case where the last line is not complete
|
||||
if (clear || logs.endsWith("\n")) {
|
||||
if (logs.endsWith("\n")) {
|
||||
tempLogLine = "";
|
||||
} else {
|
||||
tempLogLine = lines.splice(-1, 1)[0];
|
||||
}
|
||||
|
||||
const showError = lines.some(isSupervisorLogError);
|
||||
|
||||
if (lines.length) {
|
||||
this._ansiToHtmlElement?.parseLinesToColoredPre(lines);
|
||||
this._logLinesCount += lines.length;
|
||||
}
|
||||
|
||||
// only open the logs once, so they can be closed again
|
||||
if (showError && !this._errorFound) {
|
||||
this._errorFound = true;
|
||||
if (showError) {
|
||||
fireEvent(this, "landing-page-error");
|
||||
this._show = true;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,8 @@ import type {
|
||||
DockerNetwork,
|
||||
NetworkInterface,
|
||||
} from "../../../src/data/hassio/network";
|
||||
import type { HassioResolution } from "../../../src/data/hassio/resolution";
|
||||
import { handleFetchPromise } from "../../../src/util/hass-call-api";
|
||||
|
||||
// Supervisor log lines start with a color code and a timestamp, for example
|
||||
// "\x1b[31m2026-10-21 22:27:32.135+02:00 ERROR (MainThread) ..."
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const LOG_ERROR_LINE = /^(?:\x1b\[[0-9;]*m)*[\d\s.:+-]+(?:ERROR|CRITICAL)\b/;
|
||||
|
||||
export const isSupervisorLogError = (line: string): boolean =>
|
||||
LOG_ERROR_LINE.test(line);
|
||||
|
||||
export interface NetworkInfo {
|
||||
interfaces: NetworkInterface[];
|
||||
docker: DockerNetwork;
|
||||
@@ -88,13 +79,6 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
|
||||
return responseData?.data;
|
||||
}
|
||||
|
||||
export async function getSupervisorResolutionInfo(): Promise<HassioResolution> {
|
||||
const responseData = await handleFetchPromise<
|
||||
HassioResponse<HassioResolution>
|
||||
>(fetch("/supervisor-api/resolution/info"));
|
||||
return responseData?.data;
|
||||
}
|
||||
|
||||
export async function getSupervisorJobsInfo(): Promise<
|
||||
HassioResponse<SupervisorJobInfo>
|
||||
> {
|
||||
|
||||
@@ -18,7 +18,6 @@ import "./components/landing-page-network";
|
||||
import {
|
||||
getSupervisorJobsInfo,
|
||||
getSupervisorNetworkInfo,
|
||||
getSupervisorResolutionInfo,
|
||||
pingSupervisor,
|
||||
type NetworkInfo,
|
||||
} from "./data/supervisor";
|
||||
@@ -50,9 +49,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
|
||||
render() {
|
||||
const networkIssue = this._networkInfo && !this._networkInfo.host_internet;
|
||||
// Supervisor retries some failures, so keep showing a running install
|
||||
const showProgress =
|
||||
!networkIssue && (!this._supervisorError || this._progress >= 0);
|
||||
|
||||
if (!this.localize) {
|
||||
return html`
|
||||
@@ -67,7 +63,7 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
<div class="card-content">
|
||||
<h1>${this.localize("header")}</h1>
|
||||
${
|
||||
showProgress
|
||||
!networkIssue && !this._supervisorError
|
||||
? html`
|
||||
<p>${this.localize("subheader")}</p>
|
||||
<ha-progress-bar
|
||||
@@ -183,8 +179,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
this._networkInfo = await getSupervisorNetworkInfo();
|
||||
this._networkInfoError = false;
|
||||
this._coreStatusChecked = false;
|
||||
|
||||
await this._fetchSupervisorResolutionInfo();
|
||||
} catch (err: any) {
|
||||
if (await this._checkCoreAvailability()) {
|
||||
// core is available, page reload in progress -> don't show an error
|
||||
@@ -204,19 +198,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchSupervisorResolutionInfo() {
|
||||
try {
|
||||
const resolutionInfo = await getSupervisorResolutionInfo();
|
||||
if (
|
||||
resolutionInfo.issues.some((issue) => issue.type === "update_failed")
|
||||
) {
|
||||
this._supervisorError = true;
|
||||
}
|
||||
} catch (_err: any) {
|
||||
// Ignore, the logs are checked for errors as well
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchSupervisorJobsInfo() {
|
||||
try {
|
||||
const jobsInfo = await getSupervisorJobsInfo();
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
],
|
||||
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
|
||||
"translations/*/*.json": (files) =>
|
||||
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
|
||||
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
|
||||
files.join(" ") +
|
||||
" >&2 && exit 1",
|
||||
"pnpm-lock.yaml": () => "pnpm dedupe",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
@@ -63,7 +62,13 @@
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.15",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.15",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.1",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.0",
|
||||
"@lezer/common": "1.5.3",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
@@ -80,7 +85,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "7.0.1",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -92,25 +97,24 @@
|
||||
"cropperjs": "1.7.0",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.5.0",
|
||||
"js-yaml": "5.4.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"marked": "18.1.1",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +127,6 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -133,30 +136,29 @@
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0"
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@html-eslint/eslint-plugin": "0.66.1",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.27",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
@@ -165,24 +167,25 @@
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.3",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.51.0",
|
||||
"core-js-compat": "3.50.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
@@ -195,7 +198,7 @@
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.3",
|
||||
"jsdom": "30.1.2",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -214,16 +217,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.0.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]4.0",
|
||||
"packageManager": "[email protected]1.2",
|
||||
"volta": {
|
||||
"node": "24.22.0"
|
||||
"node": "24.21.0"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
"@fullcalendar/daygrid": 6.1.21
|
||||
tslib: 2.8.1
|
||||
|
||||
patchedDependencies:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -98,11 +98,6 @@
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group ECharts with zrender, which we import directly and must match the version echarts depends on",
|
||||
"groupName": "echarts",
|
||||
"matchPackageNames": ["echarts", "zrender"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
|
||||
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Add a value to a most recently used list. The value goes first, also when it
|
||||
* was already in the list, and the list is capped at `max`.
|
||||
* Entries rejected by `isValid` are dropped first, so they don't take up slots.
|
||||
*/
|
||||
export const addRecent = <T>(
|
||||
list: readonly T[] | undefined,
|
||||
value: T,
|
||||
max: number,
|
||||
isValid?: (item: T) => boolean
|
||||
): T[] => {
|
||||
const current = Array.isArray(list)
|
||||
? list.filter((item) => item !== value && (!isValid || isValid(item)))
|
||||
: [];
|
||||
return [value, ...current].slice(0, max);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { SortingChangedEvent } from "../../components/data-table/ha-data-table";
|
||||
import { getStorage } from "../decorators/storage";
|
||||
import type { HASSDomEvent } from "../dom/fire_event";
|
||||
import type { QueryParamConfig } from "../url/query-params";
|
||||
import { QueryParamsController } from "./query-params-controller";
|
||||
|
||||
export interface DataTableSettingsKeys {
|
||||
/** Session storage key for the search. */
|
||||
search?: string;
|
||||
/** Local storage key for the sorting. */
|
||||
sorting?: string;
|
||||
/** Local storage key for the grouping. */
|
||||
grouping?: string;
|
||||
}
|
||||
|
||||
export interface DataTableSettingsOptions {
|
||||
/** The grouping when none is stored. */
|
||||
defaultGrouping?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the search, sorting and grouping a user picks on a data table page.
|
||||
* What a link applies (`link` on the table's events) is shown, but not
|
||||
* stored, as links often carry someone else's choices. Bind
|
||||
* `searchChanged`, `sortingChanged` and `groupingChanged` to the table's
|
||||
* events.
|
||||
*/
|
||||
export class DataTableSettingsController implements ReactiveController {
|
||||
/** The search shown, which can be a link's. */
|
||||
public search: string;
|
||||
|
||||
/** The grouping shown, which can be a link's. */
|
||||
public grouping?: string;
|
||||
|
||||
// The search is kept for the session, sorting and grouping for good
|
||||
private _sessionStorage = getStorage("sessionStorage");
|
||||
|
||||
private _localStorage = getStorage("localStorage");
|
||||
|
||||
// Only used to know when the page is opened again
|
||||
private _queryParams: QueryParamsController<QueryParamConfig>;
|
||||
|
||||
constructor(
|
||||
private _host: ReactiveControllerHost,
|
||||
private _keys: DataTableSettingsKeys,
|
||||
private _options: DataTableSettingsOptions = {}
|
||||
) {
|
||||
this._host.addController(this);
|
||||
this._queryParams = new QueryParamsController(
|
||||
this._host,
|
||||
{},
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._navigated(),
|
||||
}
|
||||
);
|
||||
this.search = this._storedSearch();
|
||||
this.grouping = this._storedGrouping();
|
||||
}
|
||||
|
||||
/** The stored sorting, for the table to start with. */
|
||||
public get sorting(): SortingChangedEvent | undefined {
|
||||
return this._keys.sorting
|
||||
? this._localStorage.getValue<SortingChangedEvent>(this._keys.sorting)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Show a search, like one a page reads from a link, without storing it. */
|
||||
public showSearch(value: string) {
|
||||
this.search = value;
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public searchChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["search-changed"]>
|
||||
) => {
|
||||
this.showSearch(ev.detail.value);
|
||||
|
||||
if (this._keys.search && !ev.detail.link) {
|
||||
this._sessionStorage.setValue(this._keys.search, ev.detail.value);
|
||||
}
|
||||
};
|
||||
|
||||
public sortingChanged = (ev: HASSDomEvent<SortingChangedEvent>) => {
|
||||
if (this._keys.sorting && !ev.detail.link) {
|
||||
const { column, direction } = ev.detail;
|
||||
this._localStorage.setValue(this._keys.sorting, { column, direction });
|
||||
}
|
||||
};
|
||||
|
||||
public groupingChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["grouping-changed"]>
|
||||
) => {
|
||||
this.grouping = ev.detail.value;
|
||||
this._host.requestUpdate();
|
||||
|
||||
if (this._keys.grouping && !ev.detail.link) {
|
||||
this._localStorage.setValue(this._keys.grouping, ev.detail.value);
|
||||
}
|
||||
};
|
||||
|
||||
// A page opened again without params, like from the sidebar, shows what
|
||||
// the user stored rather than what a link showed before
|
||||
private _navigated() {
|
||||
if (this._queryParams.searchParams()?.toString() !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.search = this._storedSearch();
|
||||
this.grouping = this._storedGrouping();
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _storedSearch(): string {
|
||||
return (
|
||||
(this._keys.search
|
||||
? this._sessionStorage.getValue<string>(this._keys.search)
|
||||
: undefined) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
private _storedGrouping(): string | undefined {
|
||||
return (
|
||||
(this._keys.grouping
|
||||
? this._localStorage.getValue<string>(this._keys.grouping)
|
||||
: undefined) ?? this._options.defaultGrouping
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,168 +45,69 @@ export const formatNumericDuration = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const DURATION_FIELDS = [
|
||||
["days", "day"],
|
||||
["hours", "hour"],
|
||||
["minutes", "minute"],
|
||||
["seconds", "second"],
|
||||
["milliseconds", "millisecond"],
|
||||
] as const satisfies readonly (readonly [keyof HaDurationData, string])[];
|
||||
|
||||
type DurationFormatOptions = {
|
||||
style: "long" | "narrow" | "digital";
|
||||
} & Partial<Record<`${keyof HaDurationData}Display`, "always" | "auto">>;
|
||||
|
||||
type DurationFormatter = (duration: HaDurationData) => string;
|
||||
|
||||
// Throws like Intl.DurationFormat on fractions, NaN and mixed signs
|
||||
const isNegativeDuration = (duration: HaDurationData) => {
|
||||
const values = DURATION_FIELDS.map(([field]) => Number(duration[field] ?? 0));
|
||||
const negative = values.some((value) => value < 0);
|
||||
if (
|
||||
!values.every((value) => Number.isInteger(value)) ||
|
||||
(negative && values.some((value) => value > 0))
|
||||
) {
|
||||
throw new RangeError("Invalid duration");
|
||||
}
|
||||
return negative;
|
||||
};
|
||||
|
||||
const fallbackUnitsFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const style = options.style === "narrow" ? "narrow" : "long";
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style });
|
||||
const unitFormats = DURATION_FIELDS.map(
|
||||
([, unit]) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: style,
|
||||
})
|
||||
);
|
||||
return (duration) => {
|
||||
const negative = isNegativeDuration(duration);
|
||||
const parts: string[] = [];
|
||||
DURATION_FIELDS.forEach(([field], index) => {
|
||||
const value = Math.abs(duration[field] ?? 0);
|
||||
if (value || options[`${field}Display`] === "always") {
|
||||
// Like Intl.DurationFormat, only the first unit shows the sign
|
||||
parts.push(
|
||||
unitFormats[index].format(negative && !parts.length ? -value : value)
|
||||
);
|
||||
}
|
||||
});
|
||||
return list.format(parts);
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackDigitalFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style: "short" });
|
||||
const dayFormat = new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit: "day",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
const hourFormat = new Intl.NumberFormat(language, { useGrouping: false });
|
||||
const twoDigitFormat = new Intl.NumberFormat(language, {
|
||||
minimumIntegerDigits: 2,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
return (duration) => {
|
||||
const sign = isNegativeDuration(duration) ? "-" : "";
|
||||
const days = Math.abs(duration.days ?? 0);
|
||||
const hours = Math.abs(duration.hours ?? 0);
|
||||
const minutes = Math.abs(duration.minutes ?? 0);
|
||||
const seconds =
|
||||
Math.abs(duration.seconds ?? 0) +
|
||||
Math.abs(duration.milliseconds ?? 0) / 1000;
|
||||
let time = `${twoDigitFormat.format(minutes)}:${twoDigitFormat.format(seconds)}`;
|
||||
if (hours || options.hoursDisplay !== "auto") {
|
||||
time = `${hourFormat.format(hours)}:${time}`;
|
||||
}
|
||||
return days
|
||||
? list.format([sign + dayFormat.format(days), time])
|
||||
: sign + time;
|
||||
};
|
||||
};
|
||||
|
||||
// The Intl.DurationFormat polyfill needs BigInt, which some legacy browsers lack
|
||||
const createDurationFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
if ("DurationFormat" in Intl) {
|
||||
const durationFormat = new Intl.DurationFormat(language, options);
|
||||
return (duration) => durationFormat.format(duration);
|
||||
}
|
||||
return options.style === "digital"
|
||||
? fallbackDigitalFormat(language, options)
|
||||
: fallbackUnitsFormat(language, options);
|
||||
};
|
||||
|
||||
const formatDurationLongMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
const formatDurationLongMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationLong = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationLongMem(locale)(duration);
|
||||
) => formatDurationLongMem(locale).format(duration);
|
||||
|
||||
const formatDurationNarrowMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
const formatDurationNarrowMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationNarrow = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationNarrowMem(locale)(duration);
|
||||
) => formatDurationNarrowMem(locale).format(duration);
|
||||
|
||||
const formatDigitalDurationMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
const formatDigitalDurationMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationDigital = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDigitalDurationMem(locale)(duration);
|
||||
) => formatDigitalDurationMem(locale).format(duration);
|
||||
|
||||
export const DURATION_UNITS = ["min", "h", "d"] as const;
|
||||
|
||||
type DurationUnit = (typeof DURATION_UNITS)[number];
|
||||
|
||||
const formatDurationDayMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
const formatDurationDayMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationHourMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
const formatDurationHourMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationMinuteMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
const formatDurationMinuteMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDuration = (
|
||||
@@ -228,7 +129,7 @@ export const formatDuration = (
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
return formatDurationDayMem(locale)(input);
|
||||
return formatDurationDayMem(locale).format(input);
|
||||
}
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
@@ -237,7 +138,7 @@ export const formatDuration = (
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
return formatDurationHourMem(locale)(input);
|
||||
return formatDurationHourMem(locale).format(input);
|
||||
}
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
@@ -246,7 +147,7 @@ export const formatDuration = (
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
return formatDurationMinuteMem(locale)(input);
|
||||
return formatDurationMinuteMem(locale).format(input);
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid duration unit");
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const isKnownTimeZone = (zone: string | undefined): zone is string =>
|
||||
!!zone &&
|
||||
(zone === "UTC" || zone === "Etc/UTC" || timeZonesNames.includes(zone));
|
||||
|
||||
// The FormatJS polyfill reports the canonical zone (Europe/Amsterdam becomes
|
||||
// Europe/Brussels), so the name read before it loaded comes first. Its own
|
||||
// name still covers old aliases the list lacks, like Asia/Calcutta.
|
||||
const RESOLVED_TIME_ZONE = [
|
||||
globalThis.__haNativeTimeZone,
|
||||
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone,
|
||||
].find(isKnownTimeZone);
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
|
||||
|
||||
@@ -76,15 +76,11 @@ class StorageClass {
|
||||
return storageKey in this._storage;
|
||||
}
|
||||
|
||||
public getValue<T = any>(storageKey: string): T | undefined {
|
||||
if (!(storageKey in this._storage)) {
|
||||
this.addFromStorage(storageKey);
|
||||
}
|
||||
|
||||
public getValue(storageKey: string): any {
|
||||
return this._storage[storageKey];
|
||||
}
|
||||
|
||||
public setValue<T = any>(storageKey: string, value: T): void {
|
||||
public setValue(storageKey: string, value: any): any {
|
||||
const oldValue = this._storage[storageKey];
|
||||
this._storage[storageKey] = value;
|
||||
try {
|
||||
@@ -107,22 +103,9 @@ class StorageClass {
|
||||
|
||||
const storages: Record<string, StorageClass> = {};
|
||||
|
||||
export type StorageName = "localStorage" | "sessionStorage";
|
||||
|
||||
// Shared with `@storage`, so code that isn't a decorated field sees the same values
|
||||
export const getStorage = (
|
||||
storageName: StorageName = "localStorage"
|
||||
): StorageClass => {
|
||||
if (!(storageName in storages)) {
|
||||
storages[storageName] = new StorageClass(window[storageName]);
|
||||
}
|
||||
|
||||
return storages[storageName];
|
||||
};
|
||||
|
||||
export function storage(options: {
|
||||
key?: string;
|
||||
storage?: StorageName;
|
||||
storage?: "localStorage" | "sessionStorage";
|
||||
subscribe?: boolean;
|
||||
state?: boolean;
|
||||
serializer?: (value: any) => any;
|
||||
@@ -136,7 +119,15 @@ export function storage(options: {
|
||||
throw new Error("This decorator does not support this compilation type.");
|
||||
}
|
||||
|
||||
const storageInstance = getStorage(options.storage);
|
||||
const storageName = options.storage || "localStorage";
|
||||
|
||||
let storageInstance: StorageClass;
|
||||
if (storageName && storageName in storages) {
|
||||
storageInstance = storages[storageName];
|
||||
} else {
|
||||
storageInstance = new StorageClass(window[storageName]);
|
||||
storages[storageName] = storageInstance;
|
||||
}
|
||||
|
||||
const storageKey = options.key || String(propertyKey);
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ const SWIPE_LOCKED_COMPONENTS = new Set([
|
||||
"ha-control-circular-slider",
|
||||
"ha-hs-color-picker",
|
||||
"ha-map",
|
||||
// Charts pan and pinch-zoom on touch
|
||||
"ha-chart-base",
|
||||
"ha-more-info-control-select-container",
|
||||
"ha-filter-chip",
|
||||
]);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const listenMediaQuery = (
|
||||
): MediaQueriesListener => {
|
||||
const mql = matchMedia(mediaQuery);
|
||||
const listener = (e) => matchesChanged(e.matches);
|
||||
mql.addEventListener("change", listener);
|
||||
mql.addListener(listener);
|
||||
matchesChanged(mql.matches);
|
||||
return () => mql.removeEventListener("change", listener);
|
||||
return () => mql.removeListener(listener);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export const ALERT_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"binary_sensor",
|
||||
"cover",
|
||||
"lock",
|
||||
];
|
||||
|
||||
export const DEFAULT_ALERT_COLOR = "red";
|
||||
|
||||
export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
return ["triggered"];
|
||||
case "cover":
|
||||
return ["open"];
|
||||
case "lock":
|
||||
return ["jammed", "unlocked", "open"];
|
||||
default:
|
||||
return ["on"];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAlertActive = (stateObj: HassEntity): boolean =>
|
||||
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
|
||||
@@ -70,10 +70,13 @@ export const supportsWebGL2 = (): boolean => {
|
||||
|
||||
// The one gate for MapLibre, whether through the Leaflet adapter or the
|
||||
// engine `ha-map` drives directly. Its worker is bundled per build target
|
||||
// like the rest of it, so wherever the page runs, the worker runs too: the
|
||||
// legacy floor (Chrome 67 / Safari 15) guarantees the BigInt literals MapLibre
|
||||
// uses, which Babel cannot transpile.
|
||||
export const supportsVectorMaps = (): boolean => supportsWebGL2();
|
||||
// like the rest of it, so wherever the page runs, the worker runs too - with
|
||||
// one exception. Babel transpiles everything MapLibre is written in except
|
||||
// BigInt literals, which have no ES2017 spelling. A browser without BigInt
|
||||
// (Chromium below 67, such as Fire OS 5 tablets) cannot parse the chunk, and
|
||||
// a worker that cannot parse never reports back, hence the check up front.
|
||||
export const supportsVectorMaps = (): boolean =>
|
||||
supportsWebGL2() && typeof BigInt === "function";
|
||||
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs, which is why those come from VersaTiles.
|
||||
|
||||
@@ -14,6 +14,7 @@ export type LocalizeKeys =
|
||||
| `ui.card.weather.attributes.${string}`
|
||||
| `ui.card.weather.cardinal_direction.${string}`
|
||||
| `ui.card.lawn_mower.actions.${string}`
|
||||
| `ui.common.${string}`
|
||||
| `ui.components.calendar.event.rrule.${string}`
|
||||
| `ui.components.selectors.file.${string}`
|
||||
| `ui.dialogs.entity_registry.editor.${string}`
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filter-pane keys of the backups table's filters
|
||||
export const BACKUP_TYPE_FILTER = "ha-filter-states";
|
||||
export const BACKUP_LOCATIONS_FILTER = "backup-locations";
|
||||
|
||||
// The filters of the backups table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const backupsFilterQueryParamConfig = {
|
||||
list: ["type", "location"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type BackupsFilterQueryParams = QueryParamValues<
|
||||
typeof backupsFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const backupsFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): BackupsFilterQueryParams => ({
|
||||
type: list(filters[BACKUP_TYPE_FILTER]),
|
||||
location: list(filters[BACKUP_LOCATIONS_FILTER]),
|
||||
});
|
||||
|
||||
const sorted = (values?: string[]): string[] | undefined =>
|
||||
values && [...values].sort();
|
||||
|
||||
// The filters as the URL holds them, so values that filter the same, such as
|
||||
// no list and an empty one, or a list in another order, compare equal. A
|
||||
// filter list sends back what it was given in its own order.
|
||||
export const backupsFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string => {
|
||||
const { type, location } = backupsFilterQueryParams(filters);
|
||||
|
||||
return createQueryString(
|
||||
{ type: sorted(type), location: sorted(location) },
|
||||
backupsFilterQueryParamConfig
|
||||
);
|
||||
};
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping.
|
||||
export interface BackupsLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const backupsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): BackupsLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, backupsFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, backupsFilterQueryParamConfig);
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
// `type=all` asks for every type, as the backups overview links do
|
||||
[BACKUP_TYPE_FILTER]:
|
||||
params.type?.filter((type) => type && type !== "all") ?? [],
|
||||
[BACKUP_LOCATIONS_FILTER]: params.location?.filter(Boolean) ?? [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the helpers table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const helpersFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HelpersFilterQueryParams = QueryParamValues<
|
||||
typeof helpersFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const helpersFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): HelpersFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
});
|
||||
|
||||
// The filters as the URL holds them, so values that filter the same, such as
|
||||
// no list and an empty one, or a list in another order, compare equal. A
|
||||
// filter list sends back what it was given in its own order.
|
||||
export const helpersFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string => {
|
||||
const params = helpersFilterQueryParams(filters);
|
||||
const sorted: HelpersFilterQueryParams = {};
|
||||
|
||||
for (const key of helpersFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, helpersFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping.
|
||||
export interface HelpersLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const helpersLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): HelpersLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, helpersFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, helpersFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof HelpersFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
const assistants = values("assistants");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-categories": values("category"),
|
||||
"ha-filter-voice-assistants": assistants.length
|
||||
? assistants
|
||||
: values("voice_assistant"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
export const DEFAULT_LOG_PROVIDER = "core";
|
||||
|
||||
// `filter` is the name the search had before, still read until 2027.5. It is
|
||||
// in the configs so writing the search replaces it.
|
||||
export const logSearchQueryParamConfig = {
|
||||
string: ["search", "filter"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
// `raw` shows the full Home Assistant Core log instead of the errors.
|
||||
export const logsQueryParamConfig = {
|
||||
string: ["provider", "search", "filter"],
|
||||
list: ["integration"],
|
||||
boolean: [{ key: "raw", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type LogsQueryParams = QueryParamValues<typeof logsQueryParamConfig>;
|
||||
|
||||
export interface LogsState {
|
||||
provider: string;
|
||||
search: string;
|
||||
// Only apply to the Home Assistant Core log
|
||||
integrations: string[];
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOGS_STATE: LogsState = {
|
||||
provider: DEFAULT_LOG_PROVIDER,
|
||||
search: "",
|
||||
integrations: [],
|
||||
raw: false,
|
||||
};
|
||||
|
||||
export const logSearchFromUrl = (searchParams: SearchParamsSource): string => {
|
||||
const { search, filter } = decodeQueryParams(
|
||||
searchParams,
|
||||
logSearchQueryParamConfig
|
||||
);
|
||||
|
||||
return search ?? filter ?? "";
|
||||
};
|
||||
|
||||
export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
|
||||
provider:
|
||||
state.provider === DEFAULT_LOG_PROVIDER ? undefined : state.provider,
|
||||
search: state.search,
|
||||
integration:
|
||||
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
|
||||
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has none of the page's params
|
||||
// so what was picked before stays. A link replaces the search and the filter.
|
||||
export const logsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): LogsState | undefined => {
|
||||
if (!hasQueryParams(searchParams, logsQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
const params = decodeQueryParams(searchParams, logsQueryParamConfig);
|
||||
const provider = params.provider ?? DEFAULT_LOG_PROVIDER;
|
||||
const core = provider === DEFAULT_LOG_PROVIDER;
|
||||
return {
|
||||
provider,
|
||||
search: params.search ?? params.filter ?? "",
|
||||
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
|
||||
raw: core && !!params.raw,
|
||||
};
|
||||
};
|
||||
@@ -24,18 +24,3 @@ export const decodeMarketplaceFilterQueryParams = (
|
||||
export const createMarketplaceFilterQueryString = (
|
||||
values: MarketplaceFilterQueryParams
|
||||
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
|
||||
|
||||
// The filters as the URL holds them with each list sorted, so the same filters
|
||||
// in another order compare equal. A filter list sends back what it was given
|
||||
// in its own order.
|
||||
export const sortedMarketplaceFilterQueryString = (
|
||||
values: MarketplaceFilterQueryParams
|
||||
): string => {
|
||||
const sorted: MarketplaceFilterQueryParams = {};
|
||||
|
||||
for (const key of marketplaceFilterQueryParamConfig.list) {
|
||||
sorted[key] = values[key] && [...values[key]].sort();
|
||||
}
|
||||
|
||||
return createMarketplaceFilterQueryString(sorted);
|
||||
};
|
||||
@@ -4,8 +4,6 @@
|
||||
interface DeepEqualOptions {
|
||||
/** Compare Symbol properties in addition to string keys */
|
||||
compareSymbols?: boolean;
|
||||
/** Treat objects with the same keys in a different order as not equal */
|
||||
compareKeyOrder?: boolean;
|
||||
}
|
||||
|
||||
export const deepEqual = (
|
||||
@@ -92,18 +90,10 @@ export const deepEqual = (
|
||||
}
|
||||
|
||||
const keys = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
length = keys.length;
|
||||
if (length !== keysB.length) {
|
||||
if (length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
if (options?.compareKeyOrder) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (keys[i] !== keysB[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
|
||||
return false;
|
||||
|
||||
@@ -5,20 +5,14 @@ export const subscribePollingCollection = (
|
||||
updateData: (hass: HomeAssistant) => void,
|
||||
interval: number
|
||||
) => {
|
||||
let timeout: number | undefined;
|
||||
let cancelled = false;
|
||||
let timeout;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
await updateData(hass);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timeout = window.setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
timeout = setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
|
||||
const FONT_FAMILY = "Roboto, Noto, sans-serif";
|
||||
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
|
||||
const WORD_BREAK =
|
||||
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
|
||||
|
||||
interface FitLabelOptions {
|
||||
/** Font size the label is measured at, and the largest size returned */
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Height of the label at `fontSize` before ECharts wraps it */
|
||||
unwrappedHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest font size at which an ECharts label with `overflow: "break"`,
|
||||
* wrapped at `width`, fits in `height` without breaking a word.
|
||||
*/
|
||||
export function fitLabelFontSize(
|
||||
text: string,
|
||||
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
|
||||
) {
|
||||
const font = `${baseFontSize}px ${FONT_FAMILY}`;
|
||||
const measure = (label: string, wrapWidth?: number) =>
|
||||
parsePlainText(
|
||||
label,
|
||||
{ font, width: wrapWidth, overflow: "break" },
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
// ECharts measures at the final font size, leave room for rounding
|
||||
const fitWidth = Math.max(width - 1, 1);
|
||||
const unwrappedContentHeight = measure(text).contentHeight;
|
||||
const words = text.split(WORD_BREAK).filter(Boolean);
|
||||
// a label at a smaller font wraps like one at baseFontSize in a wider box
|
||||
const wrapWidthAt = (fontSize: number) =>
|
||||
(fitWidth * baseFontSize) / fontSize;
|
||||
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
|
||||
const fits = (fontSize: number) => {
|
||||
const wrapWidth = wrapWidthAt(fontSize);
|
||||
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
const wrappedHeight =
|
||||
unwrappedHeight +
|
||||
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
|
||||
// exact at maxFontSize when the label doesn't wrap
|
||||
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
|
||||
};
|
||||
let low = 0;
|
||||
let high = Math.min(baseFontSize, maxFontSize);
|
||||
if (fits(high)) {
|
||||
low = high;
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
if (fits(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// zrender moves a space that doesn't fit to the next line, indenting it.
|
||||
// Shrinking further only widens the wrap, so the label still fits.
|
||||
const spaceWidth = measure(" ").contentWidth;
|
||||
const indents = (fontSize: number) =>
|
||||
text
|
||||
.split("\n")
|
||||
.some((paragraph) =>
|
||||
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
|
||||
(line, i) => i > 0 && line.startsWith(" ")
|
||||
)
|
||||
);
|
||||
let fontSize = low;
|
||||
for (let i = 0; i < 3 && indents(fontSize); i++) {
|
||||
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
|
||||
}
|
||||
return fontSize;
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { mdiDragVerticalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { preventDefault } from "../../common/dom/prevent_default";
|
||||
import { formatNumber } from "../../common/number/format_number";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import "../ha-svg-icon";
|
||||
|
||||
// Shaped like ha-switch: a track, and the visible range as a pill with a drag
|
||||
// handle at each end
|
||||
export const ZOOM_SLIDER_HEIGHT = 24;
|
||||
const THUMB_SIZE = 20;
|
||||
const THUMB_INSET = (ZOOM_SLIDER_HEIGHT - THUMB_SIZE) / 2;
|
||||
// Leaves room to grab the range between its thumbs, however small it gets
|
||||
const RANGE_MIN_WIDTH = 2 * (THUMB_INSET + THUMB_SIZE) + ZOOM_SLIDER_HEIGHT;
|
||||
// The smallest visible range the thumbs can be dragged to, in percent
|
||||
const MIN_RANGE = 1;
|
||||
|
||||
type SliderPart = "start" | "end" | "range";
|
||||
|
||||
@customElement("ha-chart-zoom-slider")
|
||||
export class HaChartZoomSlider extends LitElement {
|
||||
@property({ type: Number }) public start = 0;
|
||||
|
||||
@property({ type: Number }) public end = 100;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
|
||||
private _drag?: {
|
||||
pointerId: number;
|
||||
part: SliderPart;
|
||||
x: number;
|
||||
range: [number, number];
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const { start, end } = this;
|
||||
const { localize, locale } = this._i18n;
|
||||
const at = (percent: number) =>
|
||||
`calc((100% - ${RANGE_MIN_WIDTH}px) * ${percent / 100})`;
|
||||
const percent = (value: number) =>
|
||||
formatNumber(value / 100, locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return html`
|
||||
<div
|
||||
class="track"
|
||||
@pointerdown=${this._handleTrackPointerDown}
|
||||
@selectstart=${preventDefault}
|
||||
>
|
||||
<div
|
||||
class="range"
|
||||
data-part="range"
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow=${Math.round(start)}
|
||||
aria-valuetext=${`${percent(start)} – ${percent(end)}`}
|
||||
aria-label=${localize("ui.components.history_charts.zoom_range")}
|
||||
tabindex="0"
|
||||
style=${styleMap({
|
||||
left: at(start),
|
||||
width: `calc(${at(end - start)} + ${RANGE_MIN_WIDTH}px)`,
|
||||
})}
|
||||
@pointerdown=${this._handlePointerDown}
|
||||
@pointermove=${this._handlePointerMove}
|
||||
@pointerup=${this._handlePointerUp}
|
||||
@pointercancel=${this._handlePointerUp}
|
||||
@keydown=${this._handleKeyDown}
|
||||
></div>
|
||||
${(["start", "end"] as const).map(
|
||||
(part) =>
|
||||
html`<div
|
||||
class="thumb"
|
||||
data-part=${part}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin=${
|
||||
part === "start"
|
||||
? 0
|
||||
: Math.round(Math.min(start + MIN_RANGE, end))
|
||||
}
|
||||
aria-valuemax=${
|
||||
part === "start"
|
||||
? Math.round(Math.max(end - MIN_RANGE, start))
|
||||
: 100
|
||||
}
|
||||
aria-valuenow=${Math.round(part === "start" ? start : end)}
|
||||
aria-valuetext=${percent(part === "start" ? start : end)}
|
||||
aria-label=${localize(`ui.components.history_charts.zoom_${part}`)}
|
||||
tabindex="0"
|
||||
style=${styleMap({
|
||||
left:
|
||||
part === "start"
|
||||
? `calc(${at(start)} + ${THUMB_INSET}px)`
|
||||
: `calc(${at(end)} + ${RANGE_MIN_WIDTH - THUMB_INSET - THUMB_SIZE}px)`,
|
||||
})}
|
||||
@pointerdown=${this._handlePointerDown}
|
||||
@pointermove=${this._handlePointerMove}
|
||||
@pointerup=${this._handlePointerUp}
|
||||
@pointercancel=${this._handlePointerUp}
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDragVerticalVariant}></ha-svg-icon>
|
||||
</div>`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Percent of the full range per pixel along the track
|
||||
private _getScale() {
|
||||
return 100 / Math.max(this.clientWidth - RANGE_MIN_WIDTH, 1);
|
||||
}
|
||||
|
||||
// Pressing the track beside the visible range centers the range there
|
||||
private _handleTrackPointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
if (ev.button !== 0 || ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
const position = (ev.offsetX - RANGE_MIN_WIDTH / 2) * this._getScale();
|
||||
this._move(
|
||||
"range",
|
||||
[this.start, this.end],
|
||||
position - (this.start + this.end) / 2
|
||||
);
|
||||
}
|
||||
|
||||
private _handlePointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
if (ev.button !== 0 || !ev.isPrimary) {
|
||||
return;
|
||||
}
|
||||
ev.currentTarget.setPointerCapture(ev.pointerId);
|
||||
this._drag = {
|
||||
pointerId: ev.pointerId,
|
||||
part: ev.currentTarget.dataset.part as SliderPart,
|
||||
x: ev.clientX,
|
||||
range: [this.start, this.end],
|
||||
};
|
||||
}
|
||||
|
||||
private _handlePointerMove(ev: PointerEvent) {
|
||||
if (ev.pointerId !== this._drag?.pointerId) {
|
||||
return;
|
||||
}
|
||||
// The release can be missed, so a move without a pressed button ends it
|
||||
if (!ev.buttons) {
|
||||
this._drag = undefined;
|
||||
return;
|
||||
}
|
||||
const { part, x, range } = this._drag;
|
||||
this._move(part, range, (ev.clientX - x) * this._getScale());
|
||||
}
|
||||
|
||||
private _handlePointerUp(ev: PointerEvent) {
|
||||
if (ev.pointerId === this._drag?.pointerId) {
|
||||
this._drag = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeyDown(
|
||||
ev: KeyboardEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
const part = ev.currentTarget.dataset.part as SliderPart;
|
||||
const span = this.end - this.start;
|
||||
const decrease = ev.key === "ArrowLeft" || ev.key === "ArrowDown";
|
||||
// A ninth of a shrunk span undoes a tenth of the original one
|
||||
const step =
|
||||
part !== "range" && (part === "start") === decrease
|
||||
? span / 9
|
||||
: span / 10;
|
||||
const shift = {
|
||||
ArrowLeft: -step,
|
||||
ArrowDown: -step,
|
||||
ArrowRight: step,
|
||||
ArrowUp: step,
|
||||
Home: -100,
|
||||
End: 100,
|
||||
}[ev.key];
|
||||
if (shift === undefined) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._move(part, [this.start, this.end], shift);
|
||||
}
|
||||
|
||||
// Moves the whole range, or one end of it, by a percentage of the full range
|
||||
private _move(
|
||||
part: SliderPart,
|
||||
[start, end]: [number, number],
|
||||
shift: number
|
||||
) {
|
||||
let newStart = start;
|
||||
let newEnd = end;
|
||||
if (part === "range") {
|
||||
const clamped = Math.max(-start, Math.min(100 - end, shift));
|
||||
newStart += clamped;
|
||||
newEnd += clamped;
|
||||
} else if (part === "start") {
|
||||
newStart = Math.min(
|
||||
Math.max(start + shift, 0),
|
||||
Math.max(end - MIN_RANGE, start)
|
||||
);
|
||||
} else {
|
||||
newEnd = Math.max(
|
||||
Math.min(end + shift, 100),
|
||||
Math.min(start + MIN_RANGE, end)
|
||||
);
|
||||
}
|
||||
if (newStart === this.start && newEnd === this.end) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "zoom-slider-changed", { start: newStart, end: newEnd });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: ${ZOOM_SLIDER_HEIGHT}px;
|
||||
}
|
||||
.track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.range {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
/* The ha-switch thumb color, which stands out in both themes */
|
||||
background-color: var(--ha-color-on-neutral-normal);
|
||||
cursor: grab;
|
||||
}
|
||||
.track,
|
||||
.range {
|
||||
transition: background-color var(--ha-animation-duration-fast) ease-in-out;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.track:hover {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
.range:hover,
|
||||
.track:has(.thumb:hover) .range {
|
||||
background-color: var(--ha-color-on-neutral-quiet);
|
||||
}
|
||||
}
|
||||
.range:active {
|
||||
cursor: grabbing;
|
||||
background-color: var(--ha-color-on-neutral-normal);
|
||||
}
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: ${THUMB_INSET}px;
|
||||
display: flex;
|
||||
width: ${THUMB_SIZE}px;
|
||||
height: ${THUMB_SIZE}px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
color: var(--ha-color-on-neutral-loud);
|
||||
--mdc-icon-size: ${THUMB_SIZE}px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.range:focus-visible,
|
||||
.thumb:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"zoom-slider-changed": { start: number; end: number };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-chart-zoom-slider": HaChartZoomSlider;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ export interface NetworkNode {
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
};
|
||||
label?: { position: "left" | "right" | "top" | "bottom" };
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* Distance from the center, where 0 is the center and 1 is the edge
|
||||
@@ -316,7 +315,6 @@ export class HaNetworkGraph extends LitElement {
|
||||
symbolSize: node.symbolSize || 30,
|
||||
symbol: node.symbol || "circle",
|
||||
itemStyle: node.itemStyle || {},
|
||||
label: node.label,
|
||||
fixed: node.fixed,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fitLabelFontSize } from "./fit-label-font-size";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import "../ha-alert";
|
||||
@@ -238,10 +237,8 @@ export class HaSankeyChart extends LitElement {
|
||||
distance: LABEL_DISTANCE,
|
||||
minMargin: LABEL_MIN_MARGIN,
|
||||
overflow: "break",
|
||||
lineOverflow: this.vertical ? "truncate" : "none",
|
||||
},
|
||||
labelLayout: (params) => {
|
||||
// params.rect is already zoomed, only the gap slack needs scaling
|
||||
if (this.vertical) {
|
||||
// reduce the label font size so the longest word fits on one line
|
||||
const longestWord = params.text
|
||||
@@ -260,7 +257,7 @@ export class HaSankeyChart extends LitElement {
|
||||
: longest;
|
||||
}, "");
|
||||
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
|
||||
const availableWidth = params.rect.width + 6 * this._currentZoom;
|
||||
const availableWidth = (params.rect.width + 6) * this._currentZoom;
|
||||
// minMargin is applied as padding on the label box, so words must
|
||||
// fit in the inner wrap width or overflow:break splits them.
|
||||
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
|
||||
@@ -269,25 +266,21 @@ export class HaSankeyChart extends LitElement {
|
||||
(wrapWidth / wordWidth) * FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize,
|
||||
// Safari draws a 0px canvas font at its default size, so hide
|
||||
// unreadable labels by dropping all of their lines instead.
|
||||
height: fontSize > 1 ? undefined : 0,
|
||||
fontSize: fontSize > 1 ? fontSize : 0,
|
||||
width: availableWidth,
|
||||
align: "center",
|
||||
dy: -2, // shift up or the lowest row labels may be cut off
|
||||
};
|
||||
}
|
||||
|
||||
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
|
||||
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
|
||||
const fontSize = Math.min(
|
||||
(availableHeight / params.labelRect.height) * FONT_SIZE,
|
||||
FONT_SIZE
|
||||
);
|
||||
return {
|
||||
// labelRect is measured before the width is applied, so unwrapped
|
||||
fontSize: fitLabelFontSize(params.text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width: labelSpace,
|
||||
height: availableHeight,
|
||||
unwrappedHeight: params.labelRect.height,
|
||||
}),
|
||||
fontSize,
|
||||
lineHeight: fontSize,
|
||||
width: labelSpace,
|
||||
height: params.rect.height,
|
||||
};
|
||||
|
||||
@@ -93,8 +93,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||
public hideResetButton?: boolean;
|
||||
|
||||
@state() private _chartData: LineSeriesOption[] = [];
|
||||
|
||||
@@ -125,7 +125,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
.expandLegend=${this.expandLegend}
|
||||
.hideZoomControls=${this.hideZoomControls}
|
||||
.hideResetButton=${this.hideResetButton}
|
||||
.clickLabelForMoreInfo=${this.clickForMoreInfo}
|
||||
@legend-label-click=${this._handleLegendLabelClick}
|
||||
></ha-chart-base>
|
||||
|
||||
@@ -60,8 +60,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public chartIndex?;
|
||||
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||
public hideResetButton?: boolean;
|
||||
|
||||
@state() private _chartData: CustomSeriesOption[] = [];
|
||||
|
||||
@@ -84,10 +84,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
grow-for-zoom-slider
|
||||
@chart-click=${this._handleChartClick}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
.hideZoomControls=${this.hideZoomControls}
|
||||
.hideResetButton=${this.hideResetButton}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
} from "../../data/history";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-history-chart-line";
|
||||
import type { StateHistoryChartLine } from "./state-history-chart-line";
|
||||
import "./state-history-chart-timeline";
|
||||
@@ -47,7 +49,6 @@ declare global {
|
||||
end: number;
|
||||
chartIndex: number;
|
||||
};
|
||||
"history-charts-zoom": { start: number; end: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +117,8 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@state() private _chartCount = 0;
|
||||
|
||||
@state() private _hasZoomedCharts = false;
|
||||
|
||||
@queryAll("state-history-chart-line, state-history-chart-timeline")
|
||||
private _chartComponents!: NodeListOf<
|
||||
StateHistoryChartLine | StateHistoryChartTimeline
|
||||
@@ -173,6 +176,18 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._renderHistoryItem(item, index)
|
||||
)}`
|
||||
}
|
||||
${
|
||||
this.syncCharts && this._hasZoomedCharts
|
||||
? html`<ha-button
|
||||
size="l"
|
||||
class="reset-button"
|
||||
@click=${this._handleGlobalZoomReset}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.history_charts.zoom_reset")}
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -206,7 +221,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||
.height=${this.virtualize ? undefined : this.height}
|
||||
.expandLegend=${this.expandLegend}
|
||||
?hide-zoom-controls=${this.syncCharts}
|
||||
?hide-reset-button=${this.syncCharts}
|
||||
></state-history-chart-line>
|
||||
</div> `;
|
||||
}
|
||||
@@ -226,7 +241,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.clickForMoreInfo=${this.clickForMoreInfo}
|
||||
@y-width-changed=${this._yWidthChanged}
|
||||
@chart-zoom-with-index=${this._handleTimelineSync}
|
||||
?hide-zoom-controls=${this.syncCharts}
|
||||
?hide-reset-button=${this.syncCharts}
|
||||
></state-history-chart-timeline>
|
||||
</div> `;
|
||||
};
|
||||
@@ -330,7 +345,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
const { start, end, chartIndex } = e.detail;
|
||||
|
||||
fireEvent(this, "history-charts-zoom", { start, end });
|
||||
this._hasZoomedCharts = start !== 0 || end !== 100;
|
||||
this._syncZoomToAllCharts(start, end, chartIndex);
|
||||
}
|
||||
|
||||
@@ -356,8 +371,21 @@ export class StateHistoryCharts extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
public zoom(start: number, end: number) {
|
||||
this._syncZoomToAllCharts(start, end);
|
||||
private _handleGlobalZoomReset() {
|
||||
this._hasZoomedCharts = false;
|
||||
this._isSyncing = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._chartComponents.forEach((chartComponent: any) => {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
if (chartBase) {
|
||||
chartBase.zoom(0, 100);
|
||||
}
|
||||
});
|
||||
this._isSyncing = false;
|
||||
});
|
||||
}
|
||||
|
||||
private _newestStateTime(): number {
|
||||
@@ -464,6 +492,13 @@ export class StateHistoryCharts extends LitElement {
|
||||
state-history-chart-line {
|
||||
width: 100%;
|
||||
}
|
||||
.reset-button {
|
||||
position: fixed;
|
||||
bottom: calc(24px + var(--safe-area-inset-bottom));
|
||||
right: calc(24px + var(--safe-area-inset-bottom));
|
||||
z-index: 1;
|
||||
--ha-button-box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaFilterChip extends HaChipBase {
|
||||
// Dispatch from the chip so listeners see the new selection before deciding
|
||||
// whether to cancel it. Preserve pointer and modifier-key information.
|
||||
const click =
|
||||
event instanceof PointerEvent
|
||||
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
|
||||
? new PointerEvent("click", event)
|
||||
: new MouseEvent("click", event);
|
||||
|
||||
|
||||
@@ -63,8 +63,6 @@ export interface CollapsedChangedEvent {
|
||||
export interface SortingChangedEvent {
|
||||
column: string;
|
||||
direction: SortingDirection;
|
||||
// Set when a link sorted the table, not the user
|
||||
link?: boolean;
|
||||
}
|
||||
|
||||
export type SortingDirection = "desc" | "asc" | null;
|
||||
@@ -1506,10 +1504,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--data-table-background-color)
|
||||
);
|
||||
background-color: var(--data-table-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
@@ -1767,6 +1762,10 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--numeric {
|
||||
text-align: var(--float-end);
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover,
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
|
||||
text-align: var(--float-start);
|
||||
}
|
||||
|
||||
/* custom from here */
|
||||
|
||||
@@ -1781,6 +1780,7 @@ export class HaDataTable extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.group-header ha-icon-button {
|
||||
@@ -1839,8 +1839,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell > * {
|
||||
transition: var(--float-start) 0.2s ease;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric ha-svg-icon {
|
||||
transition: var(--float-end) 0.2s ease;
|
||||
.mdc-data-table__header-cell--numeric > span {
|
||||
transition: none;
|
||||
}
|
||||
.mdc-data-table__header-cell ha-svg-icon {
|
||||
top: -3px;
|
||||
@@ -1863,25 +1863,6 @@ export class HaDataTable extends LitElement {
|
||||
inset-inline-start: 12px;
|
||||
inset-inline-end: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
|
||||
right: -20px;
|
||||
inset-inline-end: -20px;
|
||||
inset-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
|
||||
ha-svg-icon,
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
|
||||
ha-svg-icon {
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
inset-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
|
||||
left: -24px;
|
||||
inset-inline-start: -24px;
|
||||
inset-inline-end: initial;
|
||||
}
|
||||
.table-header {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
@@ -1931,10 +1912,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__row:has(.group-header) {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.mdc-data-table__table.auto-height .scroller {
|
||||
|
||||