Compare commits

..

27 Commits

Author SHA1 Message Date
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
Bram Kragten 91dee86697 Bumped version to 20260624.3 2026-07-01 13:34:46 +02:00
Paul Bottein 9877377cb9 Show the event type in the logbook for event entities (#52863) 2026-07-01 13:34:34 +02:00
Bram Kragten 7a1c8c556f Bumped version to 20260624.2 2026-06-30 15:58:00 +02:00
Bram Kragten 2c3d8eb230 Show a warning when deprecated automation options where used and migr… (#52915)
Show a warning when deprecated automation options where used and migrated
2026-06-30 15:57:34 +02:00
Aidan Timson 67489affe7 Fix loading spinner position for more info weather forecast (#52903) 2026-06-30 15:57:33 +02:00
Paul Bottein 7fcbd8e245 Show dedicated icons for Cloud and Cast in Actvity and add tooltip (#52896) 2026-06-30 15:57:32 +02:00
Jan-Philipp Benecke 12a88231f3 Fix overflow issue in mobile automation target picker (#52883)
Fix overflow issue in mobile target picker
2026-06-30 15:57:31 +02:00
Paulus Schoutsen f66619fae6 Fix duplicate logbook entries in more info dialog after reconnect (#52880)
* Fix duplicate logbook entries after reconnect in more info dialog

When a more info dialog is left open while the app is backgrounded, the
WebSocket connection drops and reconnects on resume. The logbook stream
subscription relied on home-assistant-js-websocket's auto-resubscribe,
which replays the original subscription with its stale start_time. The
backend then resends the entire historical chunk, and ha-logbook appends
streamed events without deduplicating, so every entry was shown twice
(and a third time after another background/reconnect cycle).

Mirror the approach already used for the history stream: disable the
library's auto-resubscribe for the logbook event stream and have
ha-logbook listen for the connection "ready" event, resubscribing from a
clean state on reconnect instead of appending a replayed history chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYMwT7ZQJrjyzrNfGQyWaU

* Simplify logbook reconnect comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-30 15:57:30 +02:00
Bram Kragten 43ff58010a Bumped version to 20260624.1 2026-06-25 16:16:23 +02:00
Copilot c391d571d7 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:15:51 +02:00
Petar Petrov 18e15f8a99 Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:15:50 +02:00
Paul Bottein dfdd55b649 Show dash for unavailable number entity in slider row (#52866) 2026-06-25 16:15:49 +02:00
Paul Bottein bed98776c3 Fix logbook padding and margin (#52864) 2026-06-25 16:15:48 +02:00
Paul Bottein ad37f1bb58 Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 16:15:47 +02:00
Franck Nijhof 2a00b0d0ec Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 16:15:46 +02:00
Paul Bottein 20efc35da3 Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:45 +02:00
Michael Hansen ac71b4c400 Add demo voice assistants and exposed entities (#52855) 2026-06-25 16:15:44 +02:00
Franck Nijhof b85422e652 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 16:15:43 +02:00
Paulus Schoutsen 4ff69aab8f Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


Claude-Session: https://claude.ai/code/session_01SYyMTtBdrt7EBrVEt869Uw

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:42 +02:00
Bram Kragten ebb15d1118 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-25 16:15:41 +02:00
1021 changed files with 39113 additions and 47741 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
<!--
If your issue is about how an entity is shown in the UI, please add the state
and attributes for all situations with a screenshot of the UI.
You can find this information at `/config/tools/state`
You can find this information at `/config/developer-tools/state`
-->
```yaml
+2 -2
View File
@@ -94,8 +94,8 @@ body:
label: State of relevant entities
description: >
If your issue is about how an entity is shown in the UI, please add the
state and attributes for all situations. You can find this
information in the Details view of the More info dialog.
state and attributes for all situations. You can find this information
at Developer Tools -> States.
render: txt
- type: textarea
attributes:
+1 -22
View File
@@ -25,8 +25,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
yarn dev # Dev server (app; --background/--status/--stop/--logs)
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
script/develop # Development server
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
@@ -496,26 +495,6 @@ this.hass.localize("ui.panel.config.updates.update_available", {
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Dev servers
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
### End-to-end (e2e) tests
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
Start the suite's dev server, then run the suite:
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
### Gallery
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
@@ -1,195 +0,0 @@
#!/usr/bin/env node
// Checks that a pull request follows the contribution standards: it must use the
// PR template, tick exactly one "Type of change" option, and describe the change.
// Labels and comments the PR when it does not, and fails the check so it blocks
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await checkStandards({ github, context, core });
export default async function checkPullRequestStandards({
github,
context,
core,
}) {
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (_error) {
core.info(
`${pr.user.login} is not an organization member, checking standards`
);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
let body = pr.body || "";
let previous;
do {
previous = body;
body = body.replace(/<!--[\s\S]*?-->/g, "");
} while (body !== previous);
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push('Select exactly one option under "Type of change".');
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
}
@@ -1,58 +0,0 @@
#!/usr/bin/env node
// Restricts Task issues to organization members: closes and labels the issue with
// an explanatory comment when the author is not an org member. Invoked from the
// `check-authorization` job in .github/workflows/restrict-task-creation.yaml via
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await checkTaskAuthorization({ github, context, core });
export default async function checkTaskAuthorization({
github,
context,
core,
}) {
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
core.info(`${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body:
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
}
-40
View File
@@ -1,40 +0,0 @@
name: Lint workflow files
on:
push:
branches:
- dev
- master
paths:
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
paths:
- ".github/workflows/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
actionlint:
name: Check workflow files
runs-on: ubuntu-latest
env:
# renovate: datasource=github-releases depName=rhysd/actionlint
ACTIONLINT_VERSION: 1.7.12
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run actionlint
run: |
curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz actionlint
./actionlint -color
+23 -8
View File
@@ -20,16 +20,31 @@ jobs:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
await checkBlockingLabels({ github, context, core });
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
+6 -8
View File
@@ -21,10 +21,10 @@ jobs:
if: github.event_name != 'push'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -46,8 +46,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --alias dev --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
npx -y netlify-cli deploy --dir=cast/dist --alias dev
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
@@ -58,10 +57,10 @@ jobs:
if: github.event_name == 'push'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
@@ -83,8 +82,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
npx -y netlify-cli deploy --dir=cast/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
+5 -18
View File
@@ -12,6 +12,7 @@ on:
env:
NODE_OPTIONS: --max_old_space_size=6144
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -26,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -39,10 +40,7 @@ jobs:
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
id: build_resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup lint cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
@@ -55,23 +53,19 @@ jobs:
- name: Run eslint
run: yarn run lint:eslint --quiet
- name: Run tsc
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:types
- name: Run lit-analyzer
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:lit --quiet
- name: Run prettier
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:prettier
- name: Check dependency licenses
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
run: yarn run lint:licenses
test:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -83,19 +77,15 @@ jobs:
run: yarn install --immutable
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run Tests
run: yarn run test
build:
name: Build frontend
needs:
- lint
- test
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -108,7 +98,6 @@ jobs:
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -116,8 +105,6 @@ jobs:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Check entrypoint bundle size budget
run: yarn run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
-43
View File
@@ -1,43 +0,0 @@
name: "CodeQL"
on:
push:
branches:
- dev
- master
pull_request:
# The branches below must be a subset of the branches above
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
contents: read # To check out the repository
security-events: write # To upload CodeQL results
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:javascript-typescript"
+65
View File
@@ -0,0 +1,65 @@
name: "CodeQL"
on:
push:
branches: [dev, master]
pull_request:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ["javascript"]
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+6 -8
View File
@@ -22,10 +22,10 @@ jobs:
if: github.event_name != 'push' || github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -47,8 +47,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
@@ -59,10 +58,10 @@ jobs:
if: github.event_name == 'push' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
@@ -84,8 +83,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
+3 -4
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
environment:
name: Design
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -40,8 +40,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
npx -y netlify-cli deploy --dir=gallery/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
+4 -3
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -47,10 +47,11 @@ jobs:
run: |
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
- name: Generate summary
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
run: |
NETLIFY_LIVE_URL=$(jq -r '.deploy_url' deploy_output.json)
echo "$NETLIFY_LIVE_URL" >> "$GITHUB_STEP_SUMMARY"
+22 -24
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -129,7 +129,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -142,38 +142,32 @@ jobs:
- name: Install dependencies
run: yarn install --immutable
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
@@ -201,7 +195,7 @@ jobs:
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -215,7 +209,7 @@ jobs:
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
@@ -235,12 +229,16 @@ jobs:
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report to PR
- name: Post report link to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { default: postReportComment } = await import(
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+161 -9
View File
@@ -1,7 +1,7 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
@@ -23,16 +23,168 @@ jobs:
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
);
await checkStandards({ github, context, core });
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -1,56 +0,0 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs:
add-no-stale:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
check-authorization:
name: Check authorization
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await checkTaskAuthorization({ github, context, core });
@@ -0,0 +1,87 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs:
add-no-stale:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
check-authorization:
name: Check authorization
runs-on: ubuntu-latest
permissions:
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});
@@ -21,9 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+4 -5
View File
@@ -6,7 +6,6 @@ on:
branches:
- dev
paths:
- .github/workflows/translations.yaml
- src/translations/en.json
permissions:
@@ -18,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Upload Translations
run: ./script/translations_upload_base
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
run: |
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
./script/translations_upload_base
-15
View File
@@ -1,15 +0,0 @@
{
"_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": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
-155
View File
@@ -1,155 +0,0 @@
/* global require, process, __dirname */
// Enforce a strict size budget on the initial JS of the most critical
// entrypoints (`app` and `core`). These two are downloaded on every cold load
// before anything interactive can happen, so unintended growth here hurts
// first-load performance directly.
//
// In production rspack does not split initial chunks (splitChunks only operates
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
// the entrypoint's initial JS size against a committed budget.
//
// Usage:
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
const fs = require("fs");
const path = require("path");
const paths = require("./paths.cjs");
// Entrypoints whose initial JS we hold to a strict budget. These are all
// downloaded on a user-facing cold load before anything interactive can happen:
// `app`/`core` for the main app, plus the standalone `authorize` and
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
// when a custom panel is opened).
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
const BUILDS = ["frontend-modern", "frontend-legacy"];
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
const STATS_DIR = path.join(paths.build_dir, "stats");
const readStats = (build) => {
const file = path.join(STATS_DIR, `${build}.json`);
if (!fs.existsSync(file)) {
throw new Error(
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
);
}
return JSON.parse(fs.readFileSync(file, "utf8"));
};
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
const entrypointInitialJS = (stats, entrypoint) => {
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
let total = 0;
let found = false;
for (const chunk of stats.chunks) {
if (!chunk.entry || !chunk.initial) {
continue;
}
if (!(chunk.names || []).includes(entrypoint)) {
continue;
}
found = true;
for (const file of chunk.files || []) {
if (file.endsWith(".js") && assetSize.has(file)) {
total += assetSize.get(file);
}
}
}
if (!found) {
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
}
return total;
};
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
const main = () => {
const update = process.argv.includes("--update");
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
const current = {};
for (const build of BUILDS) {
const stats = readStats(build);
current[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
}
}
if (update) {
const budget = { _comment: BUDGET_COMMENT };
for (const build of BUILDS) {
budget[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
budget[build][entrypoint] = Math.ceil(
current[build][entrypoint] * (1 + headroom / 100)
);
}
}
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
console.log(
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
(headroom ? ` (+${headroom}% headroom).` : ".")
);
return;
}
if (!fs.existsSync(BUDGET_FILE)) {
throw new Error(
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
);
}
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
let failed = false;
console.log("Initial JS budget (entry chunks, raw bytes):\n");
for (const build of BUILDS) {
for (const entrypoint of TRACKED_ENTRYPOINTS) {
const actual = current[build][entrypoint];
const limit = budget[build] && budget[build][entrypoint];
if (typeof limit !== "number") {
failed = true;
console.log(
`${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
);
continue;
}
const ok = actual <= limit;
const delta = (((actual - limit) / limit) * 100).toFixed(1);
console.log(
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
);
if (!ok) {
failed = true;
}
}
}
if (failed) {
console.error(
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
"If the growth is intentional, re-seed the budget:\n" +
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
);
process.exit(1);
}
console.log("\nAll tracked entrypoints within budget.");
};
const BUDGET_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>`.";
main();
-678
View File
@@ -1,678 +0,0 @@
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
//
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
//
// (no mode) Run in the foreground.
// --background Start detached, wait until it is ready, print the URL
// (when it has one) and pid, then exit and leave it running.
// --status Report whether the suite's dev server is running.
// --stop Stop a running background dev server.
// --logs [--follow] Print (or follow) the background dev server log.
//
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
// script. Suites use one of two liveness models:
//
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
// endpoint each dev server exposes (see runDevServer in
// build-scripts/gulp/rspack.js). The port is the source of truth and
// the pid is found from it; no state file.
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
// no health endpoint, and plain yarn dev has no port at all, so these
// track a pidfile and treat the first "Build done" log line as ready.
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const developAndServeScript = path.join(
repoRoot,
"script",
"develop_and_serve"
);
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
// Each suite names its yarn alias (for hints), a liveness model, and how to
// spawn it. health suites carry a fixed port; process suites carry the log line
// that means "ready" and, for app-serve, forward extra args to the script.
const SUITES = {
"e2e-app": {
alias: "test:e2e:app:dev",
liveness: "health",
port: 8095,
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
},
demo: {
alias: "dev:demo",
liveness: "health",
port: 8090,
spawn: { cmd: gulpBin, args: ["develop-demo"] },
},
gallery: {
alias: "dev:gallery",
liveness: "health",
port: 8100,
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
},
app: {
alias: "dev",
liveness: "process",
readyLog: /Build done @/,
spawn: { cmd: gulpBin, args: ["develop-app"] },
},
"app-serve": {
alias: "dev:serve",
liveness: "process",
acceptsArgs: true,
readyLog: /Build done @/,
spawn: { cmd: developAndServeScript, args: [] },
},
};
// Cover a cold build on a slow machine before the server starts listening.
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
const READY_TIMEOUT_MS =
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
// Detect a coding agent from a small set of environment markers set by common
// agent CLIs (env-only; no process-ancestry detection).
const detectAgent = () => {
const env = process.env;
const has = (name) => Boolean(env[name]);
const eq = (name, value) => env[name] === value;
const signals = {
opencode: () =>
[
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
].some(has),
"claude-code": () => has("CLAUDECODE"),
cursor: () => has("CURSOR_TRACE_ID"),
"github-copilot": () =>
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
// Convention shared by several agents (Crush, Amp, ...).
generic: () => has("AGENT") || has("AI_AGENT"),
};
return Object.keys(signals).find((id) => signals[id]());
};
const usage = () => {
const suites = Object.keys(SUITES).join("|");
process.stderr.write(
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
`[--background | --status | --stop | --logs [--follow]]\n`
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
follow: false,
suite: undefined,
passthrough: [],
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--suite":
args.suite = argv[++i];
break;
case "--background":
args.mode = "background";
break;
case "--status":
args.mode = "status";
break;
case "--stop":
args.mode = "stop";
break;
case "--logs":
args.mode = "logs";
break;
case "--follow":
args.follow = true;
break;
default:
// Anything unrecognised is forwarded to the underlying script.
args.passthrough.push(arg);
}
}
return args;
};
const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
const hints = (suite) => {
const alias = `yarn ${SUITES[suite].alias}`;
return (
` Stop: ${alias} --stop\n` +
` Status: ${alias} --status\n` +
` Logs: ${alias} --logs\n`
);
};
// --- shared spawning and lifecycle ------------------------------------------
// Signal the whole process group (the background server is its group leader),
// falling back to the bare pid if that is not permitted.
const killProcessTree = (pid, sig) => {
try {
process.kill(-pid, sig);
} catch {
try {
process.kill(pid, sig);
} catch {
// Already gone.
}
}
};
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
const spawnInherit = (cmd, args) =>
new Promise((resolve) => {
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
child.on("exit", (code) => resolve(code ?? 0));
});
// Spawn a detached server that writes stdout and stderr to the suite's log file.
const spawnDetachedToLog = (suite, cmd, args) => {
fs.mkdirSync(logDir, { recursive: true });
const logFile = logFileFor(suite);
const fd = fs.openSync(logFile, "w");
const child = spawn(cmd, args, {
cwd: repoRoot,
detached: true,
stdio: ["ignore", fd, fd],
});
fs.closeSync(fd);
child.unref();
return { child, logFile };
};
// Poll until the server is ready, the child exits, or we time out. Prints the
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
// the child dies before it is ready (used to clear a stale pidfile).
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
let childExited = false;
child.on("exit", () => {
childExited = true;
});
const deadline = Date.now() + READY_TIMEOUT_MS;
process.stdout.write(`Starting ${suite} dev server`);
/* eslint-disable no-await-in-loop -- poll until the server is ready */
while (Date.now() < deadline) {
if (childExited) {
process.stdout.write("\n");
process.stderr.write(
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
);
onExit?.();
return 1;
}
if (await isReady()) {
process.stdout.write("\n");
process.stdout.write(
`Dev server (${suite}) running${urlSuffix(port)} ` +
`(pid ${child.pid})\n${hints(suite)}`
);
return 0;
}
process.stdout.write(".");
await sleep(1000);
}
/* eslint-enable no-await-in-loop */
process.stdout.write("\n");
process.stderr.write(
`Dev server (${suite}) did not become ready within ${
READY_TIMEOUT_MS / 1000
}s. See ${logFile}\n`
);
return 1;
};
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
const terminate = async (suite, pid, isStopped, onStopped) => {
killProcessTree(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
/* eslint-disable no-await-in-loop -- poll until the server is gone */
while (Date.now() < deadline) {
await sleep(300);
if (await isStopped()) {
onStopped?.();
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
return 0;
}
}
/* eslint-enable no-await-in-loop */
// Escalate if it is still up.
killProcessTree(pid, "SIGKILL");
await sleep(300);
if (!(await isStopped())) {
process.stderr.write(
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
);
return 1;
}
onStopped?.();
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
return 0;
};
// --- health liveness (port + /__ha_dev_status) ------------------------------
/**
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
* on the OS, so try each; the port is "free" only if every address refuses.
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
*/
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
const probe = async (port, timeoutMs = 1000) => {
let sawResponse = false;
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
for (const host of PROBE_HOSTS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
signal: controller.signal,
});
sawResponse = true;
if (res.ok) {
const body = await res.json().catch(() => null);
if (body && body.server === "ha-frontend-dev") {
return { state: "ours", suite: body.suite };
}
}
} catch {
// Try the next address.
} finally {
clearTimeout(timer);
}
}
/* eslint-enable no-await-in-loop */
return sawResponse ? { state: "foreign" } : { state: "free" };
};
// Find the pid listening on a port via the first available tool (no state file).
const pidFromPort = (port) => {
const attempts = [
[
"lsof",
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
(out) => out.trim().split("\n")[0],
],
[
"ss",
["-ltnpH", `sport = :${port}`],
(out) => out.match(/pid=(\d+)/)?.[1],
],
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
];
for (const [cmd, cmdArgs, extract] of attempts) {
try {
const out = execFileSync(cmd, cmdArgs, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const pid = Number(extract(out));
if (Number.isInteger(pid) && pid > 0) {
return pid;
}
} catch {
// Try the next tool.
}
}
return undefined;
};
const runForegroundHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
process.stdout.write(
`Dev server (${suite}) is already running at http://localhost:${port}\n`
);
return 0;
}
if (status.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
};
const runBackgroundHealth = async (suite, cfg) => {
const { port } = cfg;
const preflight = await probe(port);
if (preflight.state === "ours" && preflight.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) already running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
);
return 0;
}
if (preflight.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
cfg.spawn.args
);
return awaitReady({
suite,
child,
logFile,
port,
isReady: async () => {
const status = await probe(port, 1000);
return status.state === "ours" && status.suite === suite;
},
});
};
const runStatusHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n`
);
} else if (status.state === "ours") {
process.stdout.write(
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
);
} else if (status.state === "foreign") {
process.stdout.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
} else {
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
return 0;
};
const runStopHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (!(status.state === "ours" && status.suite === suite)) {
// Idempotent: stopping something that is not running is a success.
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const pid = pidFromPort(port);
if (!pid) {
process.stderr.write(
`Dev server (${suite}) is running but its pid could not be found ` +
`(no lsof/ss/fuser?). Stop it manually.\n`
);
return 1;
}
return terminate(
suite,
pid,
async () => (await probe(port, 800)).state === "free"
);
};
// --- process liveness (pidfile + log-readiness) -----------------------------
const isAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
const readPidFile = (suite) => {
try {
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
if (data && Number.isInteger(data.pid)) {
return data;
}
} catch {
// Missing or corrupt.
}
return undefined;
};
const writePidFile = (suite, data) => {
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
};
const removePidFile = (suite) => {
try {
fs.rmSync(pidFileFor(suite));
} catch {
// Already gone.
}
};
const logIsReady = (logFile, readyLog) => {
try {
return readyLog.test(fs.readFileSync(logFile, "utf8"));
} catch {
return false;
}
};
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
const resolveServePort = (passthrough) => {
const i = passthrough.indexOf("-p");
if (i !== -1) {
const port = Number(passthrough[i + 1]);
if (Number.isInteger(port) && port > 0) {
return port;
}
}
return process.env.DEVCONTAINER ? 8123 : 8124;
};
const spawnArgs = (cfg, passthrough) => [
...cfg.spawn.args,
...(cfg.acceptsArgs ? passthrough : []),
];
const runForegroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running in the background ` +
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
);
return 0;
}
if (existing) {
removePidFile(suite);
}
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
};
const runBackgroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n${hints(suite)}`
);
return 0;
}
if (existing) {
removePidFile(suite);
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
spawnArgs(cfg, passthrough)
);
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
writePidFile(suite, { pid: child.pid, port });
return awaitReady({
suite,
child,
logFile,
port,
isReady: () => logIsReady(logFile, cfg.readyLog),
onExit: () => removePidFile(suite),
});
};
const runStatusProcess = async (suite) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n`
);
} else {
if (existing) {
removePidFile(suite);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
return 0;
};
const runStopProcess = async (suite) => {
const existing = readPidFile(suite);
if (!existing || !isAlive(existing.pid)) {
// Idempotent: stopping something that is not running is a success.
if (existing) {
removePidFile(suite);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const { pid } = existing;
return terminate(
suite,
pid,
() => !isAlive(pid),
() => removePidFile(suite)
);
};
// --- shared -----------------------------------------------------------------
const runLogs = (suite, follow) => {
const logFile = logFileFor(suite);
if (!fs.existsSync(logFile)) {
process.stdout.write(
`No log for the ${suite} dev server yet (${logFile}).\n`
);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
tail.on("error", () => {
// No tail available; fall back to a one-shot dump.
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
});
tail.on("exit", (code) => resolve(code ?? 0));
});
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const cfg = SUITES[args.suite];
if (!cfg) {
usage();
return 1;
}
if (args.passthrough.length && !cfg.acceptsArgs) {
process.stderr.write(
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
);
}
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
// are untouched.
let { mode } = args;
if (
mode === "foreground" &&
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
) {
const agent = detectAgent();
if (agent) {
process.stdout.write(
`Detected coding agent (${agent}); starting in the background. ` +
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
);
mode = "background";
}
}
const health = cfg.liveness === "health";
switch (mode) {
case "background":
return health
? runBackgroundHealth(args.suite, cfg)
: runBackgroundProcess(args.suite, cfg, args.passthrough);
case "status":
return health
? runStatusHealth(args.suite, cfg)
: runStatusProcess(args.suite);
case "stop":
return health
? runStopHealth(args.suite, cfg)
: runStopProcess(args.suite);
case "logs":
return runLogs(args.suite, args.follow);
default:
return health
? runForegroundHealth(args.suite, cfg)
: runForegroundProcess(args.suite, cfg, args.passthrough);
}
};
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
-21
View File
@@ -37,7 +37,6 @@ const isWsl =
* listenHost?: string,
* open?: boolean,
* logUrlAfterFirstBuild?: boolean,
* suite?: string,
* }}
*/
const runDevServer = async ({
@@ -48,7 +47,6 @@ const runDevServer = async ({
open = true,
logUrlAfterFirstBuild = false,
proxy = undefined,
suite = undefined,
}) => {
if (listenHost === undefined) {
// For dev container, we need to listen on all hosts
@@ -83,19 +81,6 @@ const runDevServer = async ({
!error?.message?.includes("ResizeObserver loop"),
},
},
setupMiddlewares: (middlewares) => {
// Status endpoint so the dev-server manager can confirm this is our
// server for the expected suite. Unshifted to beat the static handler.
middlewares.unshift({
name: "ha-dev-status",
path: "/__ha_dev_status",
middleware: (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ server: "ha-frontend-dev", suite, port }));
},
});
return middlewares;
},
proxy,
},
compiler
@@ -167,8 +152,6 @@ gulp.task("rspack-dev-server-demo", () =>
),
contentBase: paths.demo_output_root,
port: 8090,
open: false,
suite: "demo",
})
);
@@ -190,7 +173,6 @@ gulp.task("rspack-dev-server-cast", () =>
port: 8080,
// Accessible from the network, because that's how Cast hits it.
listenHost: "0.0.0.0",
suite: "cast",
})
);
@@ -212,7 +194,6 @@ gulp.task("rspack-dev-server-gallery", () =>
listenHost: "0.0.0.0",
open: false,
logUrlAfterFirstBuild: true,
suite: "gallery",
})
);
@@ -259,8 +240,6 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
suite: "e2e-app",
})
);
+77 -89
View File
@@ -56,100 +56,88 @@ class HcCast extends LitElement {
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
this.askWrite
${this.askWrite
? html`
<p class="question action-item">
Stay logged in?
<span>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSaveTokens}
>
YES
</ha-button>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSkipSaveTokens}
>
NO
</ha-button>
</span>
</p>
`
: ""}
${error
? html` <div class="card-content">${error}</div> `
: !this.castManager.status
? html`
<p class="question action-item">
Stay logged in?
<span>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSaveTokens}
>
YES
</ha-button>
<ha-button
appearance="plain"
size="small"
@click=${this._handleSkipSaveTokens}
>
NO
</ha-button>
</span>
<p class="center-item">
<ha-button @click=${this._handleLaunch}>
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
Start Casting
</ha-button>
</p>
`
: ""
}
${
error
? html` <div class="card-content">${error}</div> `
: !this.castManager.status
? html`
<p class="center-item">
<ha-button @click=${this._handleLaunch}>
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
Start Casting
</ha-button>
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
`
)}</ha-list
>
`
}
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${this.castManager.status?.lovelacePath ===
(view.path ?? idx)}
.selected=${this.castManager.status?.lovelacePath ===
(view.path ?? idx)}
>
${view.title || view.path || "Unnamed view"}
${view.icon
? html`
<ha-icon
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`}
</ha-list-item>
`
)}</ha-list
>
`}
<div class="card-actions">
${
this.castManager.status
? html`
<ha-button appearance="plain" @click=${this._handleLaunch}>
<ha-svg-icon
slot="start"
.path=${mdiCastConnected}
></ha-svg-icon>
Manage
</ha-button>
`
: ""
}
${this.castManager.status
? html`
<ha-button appearance="plain" @click=${this._handleLaunch}>
<ha-svg-icon
slot="start"
.path=${mdiCastConnected}
></ha-svg-icon>
Manage
</ha-button>
`
: ""}
<div class="spacer"></div>
<ha-button
variant="danger"
+3 -5
View File
@@ -135,11 +135,9 @@ export class HcConnect extends LitElement {
Show Demo
<ha-svg-icon
slot="end"
.path=${
this.castManager.castState === "CONNECTED"
? mdiCastConnected
: mdiCast
}
.path=${this.castManager.castState === "CONNECTED"
? mdiCastConnected
: mdiCast}
></ha-svg-icon>
</ha-button>
<div class="spacer"></div>
+12 -14
View File
@@ -26,20 +26,18 @@ class HcLayout extends LitElement {
/>
<h1 class="card-header">
Home Assistant Cast${this.subtitle ? ` ${this.subtitle}` : ""}
${
this.auth
? html`
<div class="subtitle">
<a href=${this.auth.data.hassUrl} target="_blank"
>${this.auth.data.hassUrl.substr(
this.auth.data.hassUrl.indexOf("//") + 2
)}</a
>
${this.user ? html` ${this.user.name} ` : ""}
</div>
`
: ""
}
${this.auth
? html`
<div class="subtitle">
<a href=${this.auth.data.hassUrl} target="_blank"
>${this.auth.data.hassUrl.substr(
this.auth.data.hassUrl.indexOf("//") + 2
)}</a
>
${this.user ? html` ${this.user.name} ` : ""}
</div>
`
: ""}
</h1>
<slot></slot>
</div>
+2 -3
View File
@@ -1,10 +1,9 @@
#!/bin/sh
# Develop the demo. Pass --background/--status/--stop/--logs to manage a
# detached instance (see build-scripts/dev-server.mjs).
# Develop the demo
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
exec node build-scripts/dev-server.mjs --suite demo "$@"
./node_modules/.bin/gulp develop-demo
+2 -1
View File
@@ -9,7 +9,8 @@ export interface DemoConfig {
authorName: string;
authorUrl: string;
description?:
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
| string
| ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
theme: () => Record<string, string> | null;
+22 -24
View File
@@ -43,30 +43,28 @@ export class HADemoCard extends LitElement implements LovelaceCard {
<ha-card>
<div class="picker">
<div class="label">
${
this._switching
? html`<ha-spinner></ha-spinner>`
: until(
selectedDemoConfig.then(
(conf) => html`
${conf.name}
<small>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{
name: html`
<a target="_blank" href=${conf.authorUrl}>
${conf.authorName}
</a>
`,
}
)}
</small>
`
),
""
)
}
${this._switching
? html`<ha-spinner></ha-spinner>`
: until(
selectedDemoConfig.then(
(conf) => html`
${conf.name}
<small>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{
name: html`
<a target="_blank" href=${conf.authorUrl}>
${conf.authorName}
</a>
`,
}
)}
</small>
`
),
""
)}
</div>
<ha-button @click=${this._nextConfig} .disabled=${this._switching}>
+1 -3
View File
@@ -66,9 +66,7 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+143 -137
View File
@@ -8,100 +8,103 @@ import type {
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEnergy = (hass: MockHomeAssistant) => {
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
energy_sources: [
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"],
},
{
type: "battery",
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
stat_rate: "sensor.power_battery",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
stat_cost: "sensor.energy_gas_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{
stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
},
{
stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
},
{
stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
},
{
stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
},
{
stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
},
{
stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
},
],
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS(
"energy/get_prefs",
(): EnergyPreferences => ({
energy_sources: [
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_1",
stat_energy_to: "sensor.energy_production_tarif_1",
stat_cost: "sensor.energy_consumption_tarif_1_cost",
stat_compensation: "sensor.energy_production_tarif_1_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
stat_energy_from: "sensor.energy_consumption_tarif_2",
stat_energy_to: "sensor.energy_production_tarif_2",
stat_cost: "sensor.energy_consumption_tarif_2_cost",
stat_compensation: "sensor.energy_production_tarif_2_compensation",
entity_energy_price: null,
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"],
},
{
type: "battery",
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
stat_rate: "sensor.power_battery",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
stat_cost: "sensor.energy_gas_cost",
entity_energy_price: null,
number_energy_price: null,
},
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{
stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
},
{
stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
},
{
stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
},
{
stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
},
{
stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
},
{
stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
},
],
})
);
hass.mockWS(
"energy/info",
(): EnergyInfo => ({ cost_sensors: {}, solar_forecast_domains: [] })
);
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
@@ -110,48 +113,51 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
);
const todayString = format(startOfToday(), "yyyy-MM-dd");
const tomorrowString = format(startOfTomorrow(), "yyyy-MM-dd");
hass.mockWS("energy/solar_forecast", (): EnergySolarForecasts => ({
solar_forecast: {
wh_hours: {
[`${todayString}T06:00:00`]: 0,
[`${todayString}T06:23:00`]: 6,
[`${todayString}T06:45:00`]: 39,
[`${todayString}T07:00:00`]: 28,
[`${todayString}T08:00:00`]: 208,
[`${todayString}T09:00:00`]: 352,
[`${todayString}T10:00:00`]: 544,
[`${todayString}T11:00:00`]: 748,
[`${todayString}T12:00:00`]: 1259,
[`${todayString}T13:00:00`]: 1361,
[`${todayString}T14:00:00`]: 1373,
[`${todayString}T15:00:00`]: 1370,
[`${todayString}T16:00:00`]: 1186,
[`${todayString}T17:00:00`]: 937,
[`${todayString}T18:00:00`]: 652,
[`${todayString}T19:00:00`]: 370,
[`${todayString}T20:00:00`]: 155,
[`${todayString}T21:48:00`]: 24,
[`${todayString}T22:36:00`]: 0,
[`${tomorrowString}T06:01:00`]: 0,
[`${tomorrowString}T06:23:00`]: 9,
[`${tomorrowString}T06:45:00`]: 47,
[`${tomorrowString}T07:00:00`]: 48,
[`${tomorrowString}T08:00:00`]: 473,
[`${tomorrowString}T09:00:00`]: 827,
[`${tomorrowString}T10:00:00`]: 1153,
[`${tomorrowString}T11:00:00`]: 1413,
[`${tomorrowString}T12:00:00`]: 1590,
[`${tomorrowString}T13:00:00`]: 1652,
[`${tomorrowString}T14:00:00`]: 1612,
[`${tomorrowString}T15:00:00`]: 1438,
[`${tomorrowString}T16:00:00`]: 1149,
[`${tomorrowString}T17:00:00`]: 830,
[`${tomorrowString}T18:00:00`]: 542,
[`${tomorrowString}T19:00:00`]: 311,
[`${tomorrowString}T20:00:00`]: 140,
[`${tomorrowString}T21:47:00`]: 22,
[`${tomorrowString}T22:34:00`]: 0,
hass.mockWS(
"energy/solar_forecast",
(): EnergySolarForecasts => ({
solar_forecast: {
wh_hours: {
[`${todayString}T06:00:00`]: 0,
[`${todayString}T06:23:00`]: 6,
[`${todayString}T06:45:00`]: 39,
[`${todayString}T07:00:00`]: 28,
[`${todayString}T08:00:00`]: 208,
[`${todayString}T09:00:00`]: 352,
[`${todayString}T10:00:00`]: 544,
[`${todayString}T11:00:00`]: 748,
[`${todayString}T12:00:00`]: 1259,
[`${todayString}T13:00:00`]: 1361,
[`${todayString}T14:00:00`]: 1373,
[`${todayString}T15:00:00`]: 1370,
[`${todayString}T16:00:00`]: 1186,
[`${todayString}T17:00:00`]: 937,
[`${todayString}T18:00:00`]: 652,
[`${todayString}T19:00:00`]: 370,
[`${todayString}T20:00:00`]: 155,
[`${todayString}T21:48:00`]: 24,
[`${todayString}T22:36:00`]: 0,
[`${tomorrowString}T06:01:00`]: 0,
[`${tomorrowString}T06:23:00`]: 9,
[`${tomorrowString}T06:45:00`]: 47,
[`${tomorrowString}T07:00:00`]: 48,
[`${tomorrowString}T08:00:00`]: 473,
[`${tomorrowString}T09:00:00`]: 827,
[`${tomorrowString}T10:00:00`]: 1153,
[`${tomorrowString}T11:00:00`]: 1413,
[`${tomorrowString}T12:00:00`]: 1590,
[`${tomorrowString}T13:00:00`]: 1652,
[`${tomorrowString}T14:00:00`]: 1612,
[`${tomorrowString}T15:00:00`]: 1438,
[`${tomorrowString}T16:00:00`]: 1149,
[`${tomorrowString}T17:00:00`]: 830,
[`${tomorrowString}T18:00:00`]: 542,
[`${tomorrowString}T19:00:00`]: 311,
[`${tomorrowString}T20:00:00`]: 140,
[`${tomorrowString}T21:47:00`]: 22,
[`${tomorrowString}T22:34:00`]: 0,
},
},
},
}));
})
);
};
+7 -4
View File
@@ -2,8 +2,11 @@ import type { EntitySources } from "../../../src/data/entity/entity_sources";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntitySources = (hass: MockHomeAssistant) => {
hass.mockWS("entity/source", (): EntitySources => ({
"sensor.co2_intensity": { domain: "co2signal" },
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
}));
hass.mockWS(
"entity/source",
(): EntitySources => ({
"sensor.co2_intensity": { domain: "co2signal" },
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
})
);
};
+8 -5
View File
@@ -2,9 +2,12 @@ import type { NetworkUrls } from "../../../src/data/network";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockNetwork = (hass: MockHomeAssistant) => {
hass.mockWS("network/url", (): NetworkUrls => ({
internal: "http://homeassistant.local:8123",
external: "https://demo-instance.ui.nabu.casa",
cloud: "https://demo-instance.ui.nabu.casa",
}));
hass.mockWS(
"network/url",
(): NetworkUrls => ({
internal: "http://homeassistant.local:8123",
external: "https://demo-instance.ui.nabu.casa",
cloud: "https://demo-instance.ui.nabu.casa",
})
);
};
+4 -3
View File
@@ -1,7 +1,8 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
hass.mockWS(
"frontend/get_translations",
(/* msg: {language: string, category: string} */) => ({ resources: {} })
);
};
-6
View File
@@ -240,12 +240,6 @@ export default tseslint.config(
globals: globals.node,
},
},
{
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+2 -3
View File
@@ -1,10 +1,9 @@
#!/bin/sh
# Run the gallery. Pass --background/--status/--stop/--logs to manage a
# detached instance (see build-scripts/dev-server.mjs).
# Run the gallery
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
exec node build-scripts/dev-server.mjs --suite gallery "$@"
./node_modules/.bin/gulp develop-gallery
@@ -101,11 +101,9 @@ class DemoBlackWhiteRow extends LitElement {
</ha-button>
</div>
</ha-card>
${
this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing
}
${this.value
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
: nothing}
</section>
</div>
`;
+6 -10
View File
@@ -34,11 +34,9 @@ class DemoCard extends LitElement {
return html`
<h2>
${this.config.heading}
${
this._size !== undefined
? html`<small>(size ${this._size})</small>`
: ""
}
${this._size !== undefined
? html`<small>(size ${this._size})</small>`
: ""}
</h2>
<div class="root">
<hui-card
@@ -46,11 +44,9 @@ class DemoCard extends LitElement {
.hass=${this.hass}
@card-updated=${this._cardUpdated}
></hui-card>
${
this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
: nothing
}
${this.showConfig
? html`<pre>${this.config.config.trim()}</pre>`
: nothing}
</div>
`;
}
+7 -9
View File
@@ -22,15 +22,13 @@ class DemoMoreInfo extends LitElement {
<div class="root">
<div id="card">
<ha-card>
${
!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing
}
${!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing}
<more-info-content
.hass=${this.hass}
+55 -165
View File
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -20,22 +19,6 @@ import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../../src/data/context";
import { updateHassGroups } from "../../src/data/context/updateContext";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
@@ -130,65 +113,6 @@ class HaGallery extends LitElement {
@state() private _drawerOpen = !this._narrow;
// Fallback Lit context providers for the whole gallery. The real app's root
// element provides these via `contextMixin`; here we mirror that so demos
// which render context-consuming components without setting up their own hass
// (e.g. bare component demos) still resolve `localize`, formatters, config,
// etc. instead of throwing during init. Demos that call `provideHass`
// register their own providers closer in the tree, which take precedence.
private _contextProviders = {
registries: new ContextProvider(this, { context: registriesContext }),
internationalization: new ContextProvider(this, {
context: internationalizationContext,
}),
api: new ContextProvider(this, { context: apiContext }),
connection: new ContextProvider(this, { context: connectionContext }),
ui: new ContextProvider(this, { context: uiContext }),
config: new ContextProvider(this, { context: configContext }),
formatters: new ContextProvider(this, { context: formattersContext }),
};
// The individual (non-grouped) contexts contextMixin also provides. Components
// such as ha-area-picker / ha-entity-picker consume these directly, so the
// fallback must cover them too.
private _singleContextProviders = {
states: new ContextProvider(this, { context: statesContext }),
services: new ContextProvider(this, { context: servicesContext }),
entities: new ContextProvider(this, { context: entitiesContext }),
devices: new ContextProvider(this, { context: devicesContext }),
areas: new ContextProvider(this, { context: areasContext }),
floors: new ContextProvider(this, { context: floorsContext }),
};
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
// the gallery hass propagate to consuming components.
const hass = this._galleryHass;
(
Object.keys(
this._contextProviders
) as (keyof typeof this._contextProviders)[]
).forEach((group) => {
const provider = this._contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
hass,
provider.value
)
);
});
(
Object.keys(
this._singleContextProviders
) as (keyof typeof this._singleContextProviders)[]
).forEach((key) => {
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
hass[key]
);
});
}
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
@@ -211,46 +135,38 @@ class HaGallery extends LitElement {
</ha-sidebar>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed .narrow=${this._narrow}>
${
this._narrow || !this._drawerOpen
? html`<ha-icon-button
slot="navigationIcon"
@click=${this._toggleDrawer}
.path=${mdiMenu}
></ha-icon-button>`
: nothing
}
${this._narrow || !this._drawerOpen
? html`<ha-icon-button
slot="navigationIcon"
@click=${this._toggleDrawer}
.path=${mdiMenu}
></ha-icon-button>`
: nothing}
<div slot="title">
${
isSettingsPage
? "Settings"
: page?.metadata.title || this._page.split("/")[1]
}
${isSettingsPage
? "Settings"
: page?.metadata.title || this._page.split("/")[1]}
</div>
<div class="content">
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@theme-settings-changed=${this._themeSettingsChanged}
@gallery-rtl-changed=${this._rtlChanged}
></gallery-settings>`
: html`
${
page?.description
? html`
<page-description .page=${this._page}>
</page-description>
`
: nothing
}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
`
}
${isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@theme-settings-changed=${this._themeSettingsChanged}
@gallery-rtl-changed=${this._rtlChanged}
></gallery-settings>`
: html`
${page?.description
? html`
<page-description .page=${this._page}>
</page-description>
`
: nothing}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
`}
</div>
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
</ha-top-app-bar-fixed>
@@ -398,15 +314,13 @@ class HaGallery extends LitElement {
.header=${group.header}
?expanded=${expanded}
>
${
group.icon
? html`<ha-svg-icon
slot="leading-icon"
class="gallery-sidebar-icon"
.path=${group.icon}
></ha-svg-icon>`
: nothing
}
${group.icon
? html`<ha-svg-icon
slot="leading-icon"
class="gallery-sidebar-icon"
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${content}
</ha-expansion-panel>
`
@@ -464,11 +378,9 @@ class HaGallery extends LitElement {
?selected=${this._page === page}
href=${`#${page}`}
>
${
iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: nothing
}
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: nothing}
<span slot="headline">${title}</span>
</ha-list-item-button>
`;
@@ -499,30 +411,23 @@ class HaGallery extends LitElement {
Suggest an edit to this page, or provide/view feedback for this page.
</div>
<div>
${
page.description || Object.keys(page.metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: nothing
}
${
page.demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: nothing
}
${page.description || Object.keys(page.metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: nothing}
${page.demo
? html`
<a href=${`${GITHUB_DEMO_URL}${this._page}.ts`} target="_blank">
Edit demo
</a>
`
: nothing}
</div>
</div>
</div>`;
@@ -671,21 +576,6 @@ class HaGallery extends LitElement {
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
formatEntityState: (stateObj, stateValue) =>
(stateValue != null ? stateValue : stateObj.state) ?? "",
formatEntityStateToParts: (stateObj, stateValue) => [
{
type: "value",
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
},
],
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
} as unknown as HomeAssistant;
}
@@ -149,11 +149,9 @@ export class DemoAutomationDescribeAction extends LitElement {
<ha-card header="Actions">
<div class="action">
<span>
${
this._action
? describeAction(this.hass, [], this._action)
: "<invalid YAML>"
}
${this._action
? describeAction(this.hass, [], this._action)
: "<invalid YAML>"}
</span>
<ha-yaml-editor
label="Action Config"
@@ -74,11 +74,9 @@ export class DemoAutomationDescribeCondition extends LitElement {
<ha-card header="Conditions">
<div class="condition">
<span>
${
this._condition
? describeCondition(this._condition, this.hass, [])
: "<invalid YAML>"
}
${this._condition
? describeCondition(this._condition, this.hass, [])
: "<invalid YAML>"}
</span>
<ha-yaml-editor
label="Condition Config"
@@ -98,11 +98,9 @@ export class DemoAutomationDescribeTrigger extends LitElement {
<ha-card header="Triggers">
<div class="trigger">
<span>
${
this._trigger
? describeTrigger(this._trigger, this.hass, [])
: "<invalid YAML>"
}
${this._trigger
? describeTrigger(this._trigger, this.hass, [])
: "<invalid YAML>"}
</span>
<ha-yaml-editor
label="Trigger Config"
+10 -12
View File
@@ -54,18 +54,16 @@ export class DemoAutomationTrace extends LitElement {
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${
selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing
}
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
+24 -36
View File
@@ -33,24 +33,20 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-assist-chip .label=${chip.content}>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
</ha-assist-chip>
`
)}
${chips.map(
(chip) => html`
<ha-assist-chip .label=${chip.content} selected>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
</ha-assist-chip>
`
)}
@@ -60,24 +56,20 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-filter-chip .label=${chip.content}>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
</ha-filter-chip>
`
)}
${chips.map(
(chip) => html`
<ha-filter-chip .label=${chip.content} selected>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
</ha-filter-chip>
`
)}
@@ -87,12 +79,10 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-input-chip .label=${chip.content}>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
${chip.content}
</ha-input-chip>
`
@@ -100,12 +90,10 @@ export class DemoHaChips extends LitElement {
${chips.map(
(chip) => html`
<ha-input-chip .label=${chip.content} selected>
${
chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing
}
${chip.icon
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
</ha-svg-icon>`
: nothing}
</ha-input-chip>
`
)}
@@ -92,16 +92,14 @@ export class DemoHaControlSelectMenu extends LitElement {
.value=${option.value}
.graphic=${option.icon ? "icon" : undefined}
>
${
option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing
}
${option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing}
${option.label ?? option.value}
</ha-list-item>
`
@@ -60,9 +60,9 @@ export class DemoHaLabelBadge extends LitElement {
${badges.map(
(badge) => html`
<ha-label-badge
style="--ha-label-badge-color: ${
colors[Math.floor(Math.random() * colors.length)]
}"
style="--ha-label-badge-color: ${colors[
Math.floor(Math.random() * colors.length)
]}"
.label=${badge.label}
.description=${badge.description}
.image=${badge.image}
@@ -78,9 +78,9 @@ export class DemoHaLabelBadge extends LitElement {
(badge) => html`
<div class="badge">
<ha-label-badge
style="--ha-label-badge-color: ${
colors[Math.floor(Math.random() * colors.length)]
}"
style="--ha-label-badge-color: ${colors[
Math.floor(Math.random() * colors.length)
]}"
.label=${badge.label}
.description=${badge.description}
.image=${badge.image}
+4 -2
View File
@@ -244,7 +244,8 @@ export class DemoHaList extends LitElement {
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
>
</ha-card>
<ha-card
@@ -271,7 +272,8 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
>
</ha-card>
<ha-card header="Option: all combinations">
+33 -8
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -522,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -543,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
@@ -696,13 +723,11 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
([key, value]) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${value?.name || key}</span>
${
value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing
}
${value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing}
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
@@ -65,34 +65,30 @@ class LandingPageLogs extends LitElement {
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
${this.localize(this._show ? "hide_details" : "show_details")}
</ha-button>
${
this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
: nothing
}
${this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
: nothing}
</div>
${
this._error
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
${this._error
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
${this.localize("logs.retry")}
</ha-button>
</ha-alert>
`
: nothing
}
${this.localize("logs.retry")}
</ha-button>
</ha-alert>
`
: nothing}
<div
class=${classMap({
logs: true,
@@ -55,15 +55,13 @@ class LandingPageNetwork extends LitElement {
})}
</p>
<p>${this.localize("network_issue.resolve_different")}</p>
${
!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
</p>
`
: nothing
}
${!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
</p>
`
: nothing}
<div class="actions">
${ALTERNATIVE_DNS_SERVERS.map(
({ translationKey }, key) =>
+33 -41
View File
@@ -61,47 +61,39 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-card>
<div class="card-content">
<h1>${this.localize("header")}</h1>
${
!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${
this._progress > 0
? `${Math.round(this._progress)}%`
: nothing
}</ha-progress-bar
>
`
: nothing
}
${
networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
></landing-page-network>
`
: nothing
}
${
this._supervisorError
? html`
<ha-alert
alert-type="error"
.title=${this.localize("error_title")}
>
${this.localize("error_description")}
</ha-alert>
`
: nothing
}
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${this._progress > 0
? `${Math.round(this._progress)}%`
: nothing}</ha-progress-bar
>
`
: nothing}
${networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
></landing-page-network>
`
: nothing}
${this._supervisorError
? html`
<ha-alert
alert-type="error"
.title=${this.localize("error_title")}
>
${this.localize("error_description")}
</ha-alert>
`
: nothing}
<landing-page-logs
.localize=${this.localize}
@landing-page-error=${this._showError}
+25 -30
View File
@@ -23,16 +23,10 @@
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
"dev": "node build-scripts/dev-server.mjs --suite app",
"dev:serve": "node build-scripts/dev-server.mjs --suite app-serve",
"dev:demo": "demo/script/develop_demo",
"dev:gallery": "gallery/script/develop_gallery",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:dev": "test/e2e/app/script/develop_app",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -42,14 +36,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.4",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.0",
"@codemirror/view": "6.43.4",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
@@ -83,10 +77,9 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.0",
"@tsparticles/preset-links": "4.3.0",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
@@ -103,12 +96,13 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.6",
"intl-messageformat": "11.2.9",
"js-yaml": "5.2.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"js-yaml": "4.2.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -145,21 +139,22 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.63.0",
"@html-eslint/eslint-plugin": "0.62.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.17",
"@rspack/core": "2.1.2",
"@rspack/dev-server": "2.1.0",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@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",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
@@ -173,19 +168,19 @@
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.6.0",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.3.1",
"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",
"fs-extra": "11.3.6",
"fs-extra": "11.3.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.6.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -200,17 +195,17 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"minify-literals": "2.0.2",
"pinst": "3.0.0",
"prettier": "3.9.4",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.19",
"tar": "7.5.16",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.62.1",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -223,7 +218,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.6.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260624.4"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1 -10
View File
@@ -11,7 +11,7 @@
"group:recommended",
"security:minimumReleaseAgeNpm"
],
"enabledManagers": ["npm", "nvm", "custom.regex"],
"enabledManagers": ["npm", "nvm"],
"postUpdateOptions": ["yarnDedupeHighest"],
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
@@ -49,15 +49,6 @@
"datasourceTemplate": "custom.ha-core-python",
"versioningTemplate": "python",
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
},
{
"description": "Keep actionlint used in CI up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/actionlint\\.yaml$/"],
"matchStrings": ["ACTIONLINT_VERSION: (?<currentValue>\\S+)"],
"depNameTemplate": "rhysd/actionlint",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
}
],
"packageRules": [
+17 -23
View File
@@ -182,11 +182,9 @@ export class HaAuthFlow extends LitElement {
@click=${this._handleSubmit}
.loading=${this._submitting}
>
${
this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")
}
${this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")}
</ha-button>
</div>
`;
@@ -226,11 +224,9 @@ export class HaAuthFlow extends LitElement {
case "form":
return html`
<h1>
${
!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")
}
${!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")}
</h1>
${this._computeStepDescription(step)}
${keyed(
@@ -248,19 +244,17 @@ export class HaAuthFlow extends LitElement {
)}
<div class="space-between">
${
this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""
}
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`
<ha-checkbox
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""}
<a
class="forgot-password"
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
+37 -50
View File
@@ -147,58 +147,45 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
}
</style>
${
!this._ownInstance
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
${
app
? this.localize("ui.panel.page-authorize.authorizing_app", {
app: appNames[this.clientId!],
})
: this.localize(
"ui.panel.page-authorize.authorizing_client",
{
clientId: html`<b
>${
this.clientId
? punycode.toASCII(this.clientId)
: this.clientId
}</b
>`,
}
)
}
</ha-alert>`
: nothing
}
${!this._ownInstance
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
${app
? this.localize("ui.panel.page-authorize.authorizing_app", {
app: appNames[this.clientId!],
})
: this.localize("ui.panel.page-authorize.authorizing_client", {
clientId: html`<b
>${this.clientId
? punycode.toASCII(this.clientId)
: this.clientId}</b
>`,
})}
</ha-alert>`
: nothing}
<div class="card-content">
${
!this._authProvider
? html`<p>
${this.localize("ui.panel.page-authorize.initializing")}
</p> `
: html`<ha-auth-flow
.clientId=${this.clientId}
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${
inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
></ha-pick-auth-provider>
`
: ""
}`
}
${!this._authProvider
? html`<p>
${this.localize("ui.panel.page-authorize.initializing")}
</p> `
: html`<ha-auth-flow
.clientId=${this.clientId}
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
></ha-pick-auth-provider>
`
: ""}`}
</div>
<div class="footer">
<ha-language-picker
+4 -1
View File
@@ -33,7 +33,10 @@ export interface ShowDemoMessage extends BaseCastMessage {
}
export type HassMessage =
ShowDemoMessage | GetStatusMessage | ConnectMessage | ShowLovelaceViewMessage;
| ShowDemoMessage
| GetStatusMessage
| ConnectMessage
| ShowLovelaceViewMessage;
export const castSendAuth = (cast: CastManager, auth: Auth) =>
cast.sendMessage({
+2 -2
View File
@@ -1,4 +1,4 @@
import { timeZonesNames } from "@vvo/tzdb";
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
timeZonesNames.includes(RESOLVED_RAW))
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
+7 -1
View File
@@ -1,7 +1,13 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
"sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
+2 -1
View File
@@ -13,7 +13,8 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
const anchor = e
.composedPath()
.find((n) => (n as HTMLElement).tagName === "A") as
HTMLAnchorElement | undefined;
| HTMLAnchorElement
| undefined;
if (
!anchor ||
anchor.target ||
@@ -82,7 +82,8 @@ export const computeAttributeValueToParts = (
: formatNumber(attributeValue, locale);
let unit = DOMAIN_ATTRIBUTES_UNITS[domain]?.[attribute] as
string | undefined;
| string
| undefined;
if (domain === "weather") {
unit = getWeatherUnit(config, stateObj as WeatherEntity, attribute);
@@ -155,7 +156,8 @@ export const computeAttributeValueToParts = (
const domain = computeDomain(entityId);
const deviceClass = stateObj.attributes.device_class;
const registryEntry = entities[entityId] as
EntityRegistryDisplayEntry | undefined;
| EntityRegistryDisplayEntry
| undefined;
const translationKey = registryEntry?.translation_key;
const formattedValue =
+2 -1
View File
@@ -14,7 +14,8 @@ export const computeEntityName = (
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
| EntityRegistryDisplayEntry
| undefined;
if (!entry) {
// Fall back to state name if not in the entity registry (friendly name)
+4 -2
View File
@@ -49,7 +49,8 @@ export const computeStateDisplay = (
state?: string
): string => {
const entity = entities?.[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
| EntityRegistryDisplayEntry
| undefined;
return computeStateDisplayFromEntityAttributes(
localize,
locale,
@@ -301,7 +302,8 @@ export const computeStateToParts = (
state?: string
): ValuePart[] => {
const entity = entities?.[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
| EntityRegistryDisplayEntry
| undefined;
return computeStateToPartsFromEntityAttributes(
localize,
locale,
@@ -24,7 +24,8 @@ export const getEntityContext = (
floors: HomeAssistant["floors"]
): EntityContext => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
| EntityRegistryDisplayEntry
| undefined;
if (!entry) {
return {
@@ -51,7 +52,9 @@ export const getEntityAreaId = (
export const getEntityEntryContext = (
entry:
EntityRegistryDisplayEntry | EntityRegistryEntry | ExtEntityRegistryEntry,
| EntityRegistryDisplayEntry
| EntityRegistryEntry
| ExtEntityRegistryEntry,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
+2 -1
View File
@@ -114,7 +114,8 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
const messageKey = key + translatedValue;
let translatedMessage = cache._localizationCache![messageKey] as
IntlMessageFormat | undefined;
| IntlMessageFormat
| undefined;
if (!translatedMessage) {
try {
@@ -10,7 +10,11 @@ import {
} from "./query-params";
export type HistoryLogbookTargetParamKey =
"entity_id" | "label_id" | "floor_id" | "area_id" | "device_id";
| "entity_id"
| "label_id"
| "floor_id"
| "area_id"
| "device_id";
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
+3 -1
View File
@@ -2,7 +2,9 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../array/ensure-array";
export type SearchParamsSource =
URLSearchParams | Record<string, string> | string;
| URLSearchParams
| Record<string, string>
| string;
export interface QueryParamConfig {
list?: readonly string[];
+4 -4
View File
@@ -27,7 +27,7 @@ export const deepEqual = (
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i], options)) {
return false;
}
@@ -71,7 +71,7 @@ export const deepEqual = (
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
@@ -94,13 +94,13 @@ export const deepEqual = (
if (length !== Object.keys(b).length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
}
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key], options)) {
+8 -1
View File
@@ -3,7 +3,14 @@ import type { FrontendLocaleData } from "../../data/translation";
import { firstWeekdayIndex } from "../datetime/first_weekday";
export type Unit =
"second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
| "second"
| "minute"
| "hour"
| "day"
| "week"
| "month"
| "quarter"
| "year";
const MS_PER_SECOND = 1e3;
const SECS_PER_MIN = 60;
+4 -4
View File
@@ -18,7 +18,7 @@ export const shallowEqual = (a: any, b: any): boolean => {
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
@@ -62,7 +62,7 @@ export const shallowEqual = (a: any, b: any): boolean => {
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
@@ -85,13 +85,13 @@ export const shallowEqual = (a: any, b: any): boolean => {
if (length !== Object.keys(b).length) {
return false;
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
}
}
for (i = length; i-- !== 0;) {
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (a[key] !== b[key]) {
@@ -56,13 +56,11 @@ export class HaAutomationConditionLiveTest extends LitElement {
)}
></ha-automation-row-live-test>
</div>
${
this._liveTestResult.message
? html`<ha-tooltip for="indicator"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing
}
${this._liveTestResult.message
? html`<ha-tooltip for="indicator"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
`;
}
+10 -12
View File
@@ -41,18 +41,16 @@ export class HaAutomationRow extends LitElement {
role="button"
@keydown=${this._handleKeydown}
>
${
this.leftChevron
? html`
<ha-icon-button
class="expand-button"
.path=${mdiChevronUp}
@click=${this._handleExpand}
@keydown=${this._handleExpand}
></ha-icon-button>
`
: nothing
}
${this.leftChevron
? html`
<ha-icon-button
class="expand-button"
.path=${mdiChevronUp}
@click=${this._handleExpand}
@keydown=${this._handleExpand}
></ha-icon-button>
`
: nothing}
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
+22 -32
View File
@@ -34,47 +34,37 @@ export class HaProgressButton extends LitElement {
.appearance=${appearance}
.disabled=${this.disabled}
.loading=${this.progress}
.variant=${
this._result === "success"
? "success"
: this._result === "error"
? "danger"
: this.variant
}
.variant=${this._result === "success"
? "success"
: this._result === "error"
? "danger"
: this.variant}
class=${classMap({
result: !!this._result,
success: this._result === "success",
error: this._result === "error",
})}
>
${
this.iconPath
? html`<ha-svg-icon
.path=${this.iconPath}
slot="start"
></ha-svg-icon>`
: nothing
}
${this.iconPath
? html`<ha-svg-icon
.path=${this.iconPath}
slot="start"
></ha-svg-icon>`
: nothing}
<slot>${this.label}</slot>
</ha-button>
${
!this._result
? nothing
: html`
<div class="progress">
${
this._result === "success"
? html`<ha-svg-icon .path=${mdiCheckBold}></ha-svg-icon>`
: this._result === "error"
? html`<ha-svg-icon
.path=${mdiAlertOctagram}
></ha-svg-icon>`
: nothing
}
</div>
`
}
${!this._result
? nothing
: html`
<div class="progress">
${this._result === "success"
? html`<ha-svg-icon .path=${mdiCheckBold}></ha-svg-icon>`
: this._result === "error"
? html`<ha-svg-icon .path=${mdiAlertOctagram}></ha-svg-icon>`
: nothing}
</div>
`}
`;
}
+43 -46
View File
@@ -352,18 +352,16 @@ export class HaChartBase extends LitElement {
<div
class="chart-controls ${classMap({ small: this.smallControls })}"
>
${
this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing
}
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
@@ -467,11 +465,9 @@ export class HaChartBase extends LitElement {
@click=${this._toggleDataset}
>
<ha-svg-icon
.path=${
this._hiddenDatasets.has(id)
? mdiCircleOutline
: mdiCheckCircle
}
.path=${this._hiddenDatasets.has(id)
? mdiCircleOutline
: mdiCheckCircle}
style=${styleMap({
color: this._hiddenDatasets.has(id) ? undefined : color,
})}
@@ -489,30 +485,26 @@ export class HaChartBase extends LitElement {
${value ? html`<div class="value">${value}</div>` : nothing}
</li>`;
})}
${
items.length > overflowLimit
? html`<li>
<ha-assist-chip
@click=${this._toggleExpandedLegend}
filled
label=${
this.expandLegend
? this.hass.localize(
"ui.components.history_charts.collapse_legend"
)
: `${this.hass.localize(
"ui.components.history_charts.expand_legend"
)} (${items.length - overflowLimit})`
}
>
<ha-svg-icon
slot="trailing-icon"
.path=${this.expandLegend ? mdiChevronUp : mdiChevronDown}
></ha-svg-icon>
</ha-assist-chip>
</li>`
: nothing
}
${items.length > overflowLimit
? html`<li>
<ha-assist-chip
@click=${this._toggleExpandedLegend}
filled
label=${this.expandLegend
? this.hass.localize(
"ui.components.history_charts.collapse_legend"
)
: `${this.hass.localize(
"ui.components.history_charts.expand_legend"
)} (${items.length - overflowLimit})`}
>
<ha-svg-icon
slot="trailing-icon"
.path=${this.expandLegend ? mdiChevronUp : mdiChevronDown}
></ha-svg-icon>
</ha-assist-chip>
</li>`
: nothing}
</ul>
</div>`;
}
@@ -675,7 +667,8 @@ export class HaChartBase extends LitElement {
): string[] {
if (!options) return [primaryId];
const legend = ensureArray(this.options?.legend || [])[0] as
LegendComponentOption | undefined;
| LegendComponentOption
| undefined;
let customLegendItem;
if (legend?.type === "custom") {
@@ -692,7 +685,8 @@ export class HaChartBase extends LitElement {
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
LegendComponentOption | undefined;
| LegendComponentOption
| undefined;
Object.entries(legend?.selected || {}).forEach(([stat, selected]) => {
if (selected === false) {
this._getAllIdsFromLegend(options, stat).forEach((id) =>
@@ -705,9 +699,11 @@ export class HaChartBase extends LitElement {
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
YAXisOption | undefined;
| YAXisOption
| undefined;
if (xAxis?.type === "value" && yAxis?.type === "category") {
// vertical data zoom doesn't work well in this case and horizontal is pointless
return undefined;
@@ -1018,7 +1014,8 @@ export class HaChartBase extends LitElement {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
| XAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
@@ -205,9 +205,9 @@ export class StateHistoryChartLine extends LitElement {
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${
param.seriesName ? html`${param.seriesName}: ` : nothing
}${value}${statSuffix}`;
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
};
@@ -145,11 +145,9 @@ export class StateHistoryChartTimeline extends LitElement {
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${
seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing
}<ha-chart-tooltip-marker
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
+25 -29
View File
@@ -149,36 +149,32 @@ export class StateHistoryCharts extends LitElement {
this._chartCount = combinedItems.length;
return html`
${
this.virtualize
? html`<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
${this.virtualize
? html`<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
>
<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${combinedItems}
.renderItem=${this._renderHistoryItem}
>
<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${combinedItems}
.renderItem=${this._renderHistoryItem}
>
</lit-virtualizer>
</div>`
: html`${combinedItems.map((item, index) =>
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
}
</lit-virtualizer>
</div>`
: html`${combinedItems.map((item, index) =>
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}
`;
}
+9 -8
View File
@@ -79,7 +79,10 @@ export class StatisticsChart extends LitElement {
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType:
"line" | "line-stack" | "bar" | "bar-stack" = "line";
| "line"
| "line-stack"
| "bar"
| "bar-stack" = "line";
@property({ attribute: false }) public minYAxis?: number;
@@ -194,10 +197,8 @@ export class StatisticsChart extends LitElement {
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
.expandLegend=${this.expandLegend}
.clickLabelForMoreInfo=${
this.clickForMoreInfo &&
!this._statisticIds.every(isExternalStatistic)
}
.clickLabelForMoreInfo=${this.clickForMoreInfo &&
!this._statisticIds.every(isExternalStatistic)}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
`;
@@ -329,9 +330,9 @@ export class StatisticsChart extends LitElement {
return html`${rows.map(
(row, i) =>
html`${
row.time ? html`${row.time}<br />` : nothing
}<ha-chart-tooltip-marker
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
@@ -161,15 +161,13 @@ export class DialogDataTableSettings extends LitElement {
graphic="icon"
noninteractive
>${col.title || col.label || col.key}
${
canMove && isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
slot="graphic"
></ha-svg-icon>`
: nothing
}
${canMove && isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
slot="graphic"
></ha-svg-icon>`
: nothing}
<ha-icon-button
tabindex="0"
class="action"
@@ -61,34 +61,29 @@ class HaDataTableLabels extends LitElement {
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${
hidden > 0
? html`
<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${hidden}
</ha-label>
${repeat(
labels.slice(this._visibleCount),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item
.value=${label.label_id}
.item=${label}
>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>
`
: nothing
}
${hidden > 0
? html`
<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${hidden}
</ha-label>
${repeat(
labels.slice(this._visibleCount),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>
`
: nothing}
</ha-chip-set>
</div>
@@ -192,11 +187,9 @@ class HaDataTableLabels extends LitElement {
@keydown=${clickAction ? this._labelClicked : undefined}
.description=${label.description}
>
${
label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing
}
${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
`;
+132 -167
View File
@@ -423,19 +423,17 @@ export class HaDataTable extends LitElement {
return html`
<div class="mdc-data-table">
<slot name="header" @slotchange=${this._calcTableHeight}>
${
this._filterable
? html`
<div class="table-header">
<ha-input-search
appearance="outlined"
@input=${this._handleSearchChange}
.placeholder=${this.searchLabel}
></ha-input-search>
</div>
`
: ""
}
${this._filterable
? html`
<div class="table-header">
<ha-input-search
appearance="outlined"
@input=${this._handleSearchChange}
.placeholder=${this.searchLabel}
></ha-input-search>
</div>
`
: ""}
</slot>
<div
class="mdc-data-table__table ${classMap({
@@ -456,32 +454,24 @@ export class HaDataTable extends LitElement {
@scroll=${this._scrollContent}
>
<slot name="header-row">
${
this.selectable
? html`
<div
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
${this.selectable
? html`
<div
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${!!this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${!!this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${
!!this._checkedRows.length &&
this._checkedRows.length !==
this._checkableRowsCount
}
.checked=${
!!this._checkedRows.length &&
this._checkedRows.length ===
this._checkableRowsCount
}
>
</ha-checkbox>
</div>
`
: ""
}
</ha-checkbox>
</div>
`
: ""}
${Object.entries(columns).map(([key, column]) => {
if (
column.hidden ||
@@ -527,74 +517,63 @@ export class HaDataTable extends LitElement {
.columnId=${key}
title=${ifDefined(column.title)}
>
${
column.sortable
? html`
<ha-svg-icon
.path=${
sorted && this.sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp
}
></ha-svg-icon>
`
: ""
}
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this.sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: ""}
<span>${column.title}</span>
</div>
`;
})}
</slot>
</div>
${
!this._filteredData?.length
? html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div
class="mdc-data-table__cell grows center"
role="cell"
>
${
!this._filteredData
? this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
? this._i18n?.localize?.(
"ui.components.data-table.no_match_filter"
) || "No rows matching current filters"
: this.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"
}
</div>
${!this._filteredData?.length
? html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${!this._filteredData
? this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
? this._i18n?.localize?.(
"ui.components.data-table.no_match_filter"
) || "No rows matching current filters"
: this.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
"No data"}
</div>
</div>
`
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
this._collapsedGroups,
this.sortColumn,
this.sortDirection
)}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
`
}
</div>
`
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
this._i18n?.localize,
this._i18n?.locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
this._collapsedGroups,
this.sortColumn,
this.sortDirection
)}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
`}
</div>
</div>
`;
@@ -635,25 +614,23 @@ export class HaDataTable extends LitElement {
)}
.selectable=${row.selectable !== false}
>
${
this.selectable
? html`
<div
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
role="cell"
${this.selectable
? html`
<div
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
role="cell"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@click=${this._handleRowCheckboxClicked}
.rowId=${row[this.id]}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(String(row[this.id]))}
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@click=${this._handleRowCheckboxClicked}
.rowId=${row[this.id]}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(String(row[this.id]))}
>
</ha-checkbox>
</div>
`
: ""
}
</ha-checkbox>
</div>
`
: ""}
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
@@ -686,46 +663,38 @@ export class HaDataTable extends LitElement {
flex: column.flex || 1,
})}
>
${
column.template
? column.template(row)
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
</div>
${
column.extraTemplate
? column.extraTemplate(row)
: nothing
}`
: html`${row[key]}${
column.extraTemplate
? column.extraTemplate(row)
: nothing
}`
}
${column.template
? column.template(row)
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${i !== 0
? STRINGS_SEPARATOR_DOT
: nothing}${column2.template
? column2.template(row)
: row[key2]}`
)}
</div>
${column.extraTemplate
? column.extraTemplate(row)
: nothing}`
: html`${row[key]}${column.extraTemplate
? column.extraTemplate(row)
: nothing}`}
</div>
`;
})}
@@ -862,20 +831,16 @@ export class HaDataTable extends LitElement {
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${
localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")
}
.label=${localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
: groupName || ""
}
${groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
: groupName || ""}
</div>`,
});
if (!collapsedGroups.includes(groupName)) {
+31 -35
View File
@@ -143,11 +143,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
render() {
return html`<div class="picker">
${
this.ranges !== false && this.ranges
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
: nothing
}
${this.ranges !== false && this.ranges
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
: nothing}
<div class="range">
<calendar-range
.value=${this._dateValue}
@@ -178,36 +176,34 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
></ha-icon-button-next>
<calendar-month></calendar-month>
</calendar-range>
${
this.timePicker
? html`
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
: nothing
}
${this.timePicker
? html`
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this._i18n.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
: nothing}
</div>
</div>
<div class="footer">
@@ -131,106 +131,98 @@ export class HaDateRangePicker extends LitElement {
return html`
<div class="container">
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
${!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))}
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`}
</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
>
${this._renderPicker()}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
class=${this._opened ? "open" : ""}
without-arrow
distance="0"
.placement=${this.popoverPlacement}
for="field"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-hide=${this._handlePopoverHide}
@wa-after-hide=${this._hidePicker}
trap-focus
>
${this._renderPicker()}
</wa-popover>
`
: nothing
}
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
>
${this._renderPicker()}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
class=${this._opened ? "open" : ""}
without-arrow
distance="0"
.placement=${this.popoverPlacement}
for="field"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-hide=${this._handlePopoverHide}
@wa-after-hide=${this._hidePicker}
trap-focus
>
${this._renderPicker()}
</wa-popover>
`
: nothing}
</div>
`;
}
@@ -105,25 +105,21 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
.dialogAnchor=${this.dialogAnchor}
open
width="small"
.headerTitle=${
this._value?.title ||
this._i18n.localize("ui.dialogs.date-picker.title")
}
.headerTitle=${this._value?.title ||
this._i18n.localize("ui.dialogs.date-picker.title")}
.headerSubtitle=${this._value?.year}
header-subtitle-position="above"
>
${
this.params.canClear
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
`
: nothing
}
${this.params.canClear
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
`
: nothing}
<wa-divider></wa-divider>
<calendar-date
.value=${this._value?.dateString}
+44 -52
View File
@@ -165,24 +165,22 @@ export class HaDevicePicker extends LitElement {
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
${configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
@@ -191,42 +189,36 @@ export class HaDevicePicker extends LitElement {
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
${item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
+8 -10
View File
@@ -111,16 +111,14 @@ class HaEntitiesPicker extends LitElement {
.createDomains=${this.createDomains}
@value-changed=${this._entityChanged}
></ha-entity-picker>
${
this.reorder
? html`
<ha-svg-icon
class="entity-handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${this.reorder
? html`
<ha-svg-icon
class="entity-handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing}
</div>
`
)}
@@ -86,12 +86,10 @@ class HaEntityAttributePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.value=${this.value}
.label=${
this.label ??
this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
)
}
.label=${this.label ??
this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
)}
.disabled=${this.disabled || !this.entityId}
.required=${this.required}
.helper=${this.helper}
+30 -36
View File
@@ -27,11 +27,9 @@ import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
@@ -63,7 +61,9 @@ export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public entityId?: string;
@property({ attribute: false }) public value?:
string | EntityNameItem | EntityNameItem[];
| string
| EntityNameItem
| EntityNameItem[];
@property() public label?: string;
@@ -125,22 +125,18 @@ export class HaEntityNamePicker extends LitElement {
></ha-button-toggle-group>
</div>
<div class="content">
${
this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()
}
${this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()}
</div>
</div>
${
this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing
}
${this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing}
`;
}
@@ -214,22 +210,20 @@ export class HaEntityNamePicker extends LitElement {
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>

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