mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-25 01:32:30 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 418327d6fe | |||
| e637c0c583 |
@@ -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.yml 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"],
|
||||
});
|
||||
}
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -229,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@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,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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 });
|
||||
|
||||
@@ -36,21 +36,52 @@ jobs:
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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 });
|
||||
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']
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
import yaml from "js-yaml";
|
||||
import { load as loadYaml } 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 = yaml.load(descriptionContent.substring(3, metadataEnd));
|
||||
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
|
||||
descriptionContent = descriptionContent
|
||||
.substring(metadataEnd + 3)
|
||||
.trim();
|
||||
|
||||
+1
-4
@@ -53,7 +53,6 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -66,9 +65,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(
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
import type { AssistPipeline } from "../../../src/data/assist_pipeline";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const pipelines: AssistPipeline[] = [
|
||||
{
|
||||
id: "01home_assistant_cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "cloud",
|
||||
stt_language: "en-US",
|
||||
tts_engine: "cloud",
|
||||
tts_language: "en-US",
|
||||
tts_voice: "JennyNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
{
|
||||
id: "01local",
|
||||
name: "Local",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.faster_whisper",
|
||||
stt_language: "en",
|
||||
tts_engine: "tts.piper",
|
||||
tts_language: "en",
|
||||
tts_voice: null,
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns a cloud and a local pipeline so the
|
||||
// voice assistants config panel shows configured assistants.
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines,
|
||||
preferred_pipeline: "01home_assistant_cloud",
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { mockApplicationCredentials } from "./application_credentials";
|
||||
import { mockAssist } from "./assist";
|
||||
import { mockAutomation } from "./automation";
|
||||
import { mockBackup } from "./backup";
|
||||
import { mockBlueprint } from "./blueprint";
|
||||
@@ -38,5 +37,4 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockScene(hass);
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
mockAssist(hass);
|
||||
};
|
||||
|
||||
@@ -2,27 +2,22 @@ import type { ExposeEntitySettings } from "../../../src/data/expose";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const exposedEntities: Record<string, ExposeEntitySettings> = {
|
||||
"light.floor_lamp": {
|
||||
"light.bed_light": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.living_room_spotlights": {
|
||||
"light.ceiling_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": false,
|
||||
},
|
||||
"light.bar_lamp": {
|
||||
"switch.decorative_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": false,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.kitchen_spotlights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.outdoor_light": {
|
||||
"climate.ecobee": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
|
||||
@@ -240,12 +240,6 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -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];
|
||||
@@ -652,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-3
@@ -102,7 +102,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.5",
|
||||
"intl-messageformat": "11.2.8",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.0.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",
|
||||
@@ -144,7 +144,7 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.0",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@rsdoctor/rspack-plugin": "1.5.15",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
@@ -154,7 +154,6 @@
|
||||
"@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",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.0"
|
||||
version = "20260527.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export default function durationToSeconds(duration: string): number {
|
||||
const parts = duration.split(":").map(Number);
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
export const durationDataToSeconds = (duration: HaDurationData): number =>
|
||||
(duration.days || 0) * 86400 +
|
||||
(duration.hours || 0) * 3600 +
|
||||
(duration.minutes || 0) * 60 +
|
||||
(duration.seconds || 0) +
|
||||
(duration.milliseconds || 0) / 1000;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Schema } from "js-yaml";
|
||||
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
|
||||
import { dump, load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
|
||||
export class HaYamlEditor extends LitElement {
|
||||
@property() public value?: any;
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
|
||||
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
|
||||
|
||||
@property({ attribute: false }) public defaultValue?: any;
|
||||
|
||||
@@ -70,7 +70,6 @@ export class HaYamlEditor extends LitElement {
|
||||
this._yaml = !isEmpty(value)
|
||||
? dump(value, {
|
||||
schema: this.yamlSchema,
|
||||
quotingType: '"',
|
||||
noRefs: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
@@ -331,14 +331,7 @@ export interface AutomationElementGroupCollection {
|
||||
|
||||
export type AutomationElementGroup = Record<
|
||||
string,
|
||||
{
|
||||
icon?: string;
|
||||
members?: AutomationElementGroup;
|
||||
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
|
||||
// are bundled into this group instead of appearing as their own dynamic
|
||||
// domain group.
|
||||
domains?: string[];
|
||||
}
|
||||
{ icon?: string; members?: AutomationElementGroup }
|
||||
>;
|
||||
|
||||
export type LegacyCondition =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
@@ -9,14 +9,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
dynamicGroups: {},
|
||||
time: {
|
||||
icon: mdiClockOutline,
|
||||
members: { time: {} },
|
||||
domains: ["calendar", "schedule"],
|
||||
},
|
||||
sun: {
|
||||
icon: mdiWeatherSunny,
|
||||
domains: ["sun"],
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { sun: {}, time: {}, zone: {} },
|
||||
},
|
||||
helpers: {},
|
||||
template: {},
|
||||
|
||||
@@ -153,21 +153,6 @@ export const getRecorderInfo = (conn: Connection) =>
|
||||
type: "recorder/info",
|
||||
});
|
||||
|
||||
export type EntityRecordingDisabler = "user";
|
||||
|
||||
export interface RecorderEntityOptions {
|
||||
recording_disabled_by: EntityRecordingDisabler | null;
|
||||
}
|
||||
|
||||
export const getRecorderEntityOptions = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entity_id: string
|
||||
) =>
|
||||
hass.callWS<RecorderEntityOptions>({
|
||||
type: "recorder/entity_options/get",
|
||||
entity_id,
|
||||
});
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_type?: "mean" | "sum"
|
||||
|
||||
+6
-8
@@ -1,4 +1,4 @@
|
||||
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
@@ -14,17 +14,15 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
dynamicGroups: {},
|
||||
time: {
|
||||
icon: mdiClockOutline,
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: {
|
||||
calendar: {},
|
||||
sun: {},
|
||||
time: {},
|
||||
time_pattern: {},
|
||||
zone: {},
|
||||
},
|
||||
domains: ["calendar", "schedule"],
|
||||
},
|
||||
sun: {
|
||||
icon: mdiWeatherSunny,
|
||||
domains: ["sun"],
|
||||
},
|
||||
event: {},
|
||||
geo_location: {},
|
||||
|
||||
@@ -9,17 +9,11 @@ import { computeFormatFunctions } from "../common/translations/entity-state";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import { updateHassGroups } from "../data/context/updateContext";
|
||||
@@ -103,15 +97,11 @@ export const provideHass = (
|
||||
elements,
|
||||
overrideData: Partial<HomeAssistant> = {},
|
||||
setHassProperty = false,
|
||||
// Provide the grouped Lit contexts (registries, internationalization, api,
|
||||
// connection, ui, config, formatters) that the real app's root element
|
||||
// provides via `contextMixin`. On by default so that any standalone hass root
|
||||
// (e.g. a gallery demo) automatically feeds context-consuming components the
|
||||
// same way the real app does, instead of each demo wiring up a partial set by
|
||||
// hand. Pass `false` for hosts that already provide these contexts themselves
|
||||
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
|
||||
// registering duplicate providers on the same element.
|
||||
provideContexts = true
|
||||
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
|
||||
// that the real app's root element provides via `contextMixin`. Needed for
|
||||
// gallery demos that render context-consuming components (e.g. the climate
|
||||
// temperature control) without the full app shell.
|
||||
provideContexts = false
|
||||
): MockHomeAssistant => {
|
||||
elements = ensureArray(elements);
|
||||
// Can happen because we store sidebar, more info etc on hass.
|
||||
@@ -138,46 +128,21 @@ export const provideHass = (
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// The individual (non-grouped) contexts that contextMixin also provides.
|
||||
// Components such as ha-area-picker / ha-entity-picker consume these directly
|
||||
// (e.g. `Object.values(areas)`), so they must be provided alongside the
|
||||
// grouped contexts or those components throw once they render.
|
||||
const singleContextProviders = provideContexts
|
||||
? {
|
||||
states: new ContextProvider(baseEl(), { context: statesContext }),
|
||||
services: new ContextProvider(baseEl(), { context: servicesContext }),
|
||||
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
|
||||
devices: new ContextProvider(baseEl(), { context: devicesContext }),
|
||||
areas: new ContextProvider(baseEl(), { context: areasContext }),
|
||||
floors: new ContextProvider(baseEl(), { context: floorsContext }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const updateContextProviders = (newHass: HomeAssistant) => {
|
||||
if (contextProviders) {
|
||||
(
|
||||
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
newHass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (singleContextProviders) {
|
||||
(
|
||||
Object.keys(
|
||||
singleContextProviders
|
||||
) as (keyof typeof singleContextProviders)[]
|
||||
).forEach((key) => {
|
||||
(singleContextProviders[key] as ContextProvider<any>).setValue(
|
||||
newHass[key]
|
||||
);
|
||||
});
|
||||
if (!contextProviders) {
|
||||
return;
|
||||
}
|
||||
(
|
||||
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
|
||||
).forEach((group) => {
|
||||
const provider = contextProviders[group];
|
||||
provider.setValue(
|
||||
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
|
||||
newHass,
|
||||
provider.value
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const wsCommands = {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiDotsVertical } from "@mdi/js";
|
||||
import { DEFAULT_SCHEMA, Type } from "js-yaml";
|
||||
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -47,12 +47,11 @@ const SUPPORTED_UI_TYPES = [
|
||||
"schema",
|
||||
];
|
||||
|
||||
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
|
||||
new Type("!secret", {
|
||||
kind: "scalar",
|
||||
construct: (data) => `!secret ${data}`,
|
||||
}),
|
||||
]);
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
const MASKED_FIELDS = ["password", "secret", "token"];
|
||||
|
||||
|
||||
@@ -192,11 +192,6 @@ const DYNAMIC_KEYWORDS = [
|
||||
|
||||
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
|
||||
|
||||
// Group keys surfaced as their own section in the "by target" tab because
|
||||
// their elements have no target (time/calendar/schedule, sun). Picking one
|
||||
// drills into its items, like selecting the matching group in the "by type" tab.
|
||||
const TIME_LOCATION_GROUPS = ["time", "sun"];
|
||||
|
||||
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
|
||||
|
||||
@customElement("add-automation-element-dialog")
|
||||
@@ -688,28 +683,13 @@ class DialogAddAutomationElement
|
||||
.hass=${this.hass}
|
||||
.value=${this._selectedTarget}
|
||||
@value-changed=${this._handleTargetSelected}
|
||||
@time-location-group-selected=${this
|
||||
._handleTimeLocationGroupSelected}
|
||||
.narrow=${this._narrow}
|
||||
.timeLocationLabel=${this._getTimeLocationLabel(
|
||||
automationElementType
|
||||
)}
|
||||
.timeLocationGroups=${this._getTimeLocationGroups(
|
||||
automationElementType,
|
||||
this.hass.localize,
|
||||
automationElementType === "condition"
|
||||
? this._conditionDescriptions
|
||||
: this._triggerDescriptions
|
||||
)}
|
||||
.selectedGroup=${this._selectedGroup}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
hidden:
|
||||
!!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
) ||
|
||||
(this._narrow && !!this._selectedGroup),
|
||||
hidden: !!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
),
|
||||
})}
|
||||
.manifests=${this._manifests}
|
||||
></ha-automation-add-from-target>`
|
||||
@@ -815,13 +795,12 @@ class DialogAddAutomationElement
|
||||
)
|
||||
: undefined}
|
||||
.selectLabel=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
|
||||
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
|
||||
)}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
|
||||
)}
|
||||
.tooltipDescription=${this._tab === "targets" &&
|
||||
!this._selectedGroup}
|
||||
.tooltipDescription=${this._tab === "targets"}
|
||||
.target=${(this._tab === "targets" &&
|
||||
this._selectedTarget &&
|
||||
([
|
||||
@@ -981,7 +960,7 @@ class DialogAddAutomationElement
|
||||
items: this._getBlockItems(this._params!.type, this.hass.localize),
|
||||
},
|
||||
]
|
||||
: !this._filter && this._selectedGroup
|
||||
: !this._filter && this._tab === "groups" && this._selectedGroup
|
||||
? [
|
||||
{
|
||||
title: this.hass.localize(
|
||||
@@ -1039,10 +1018,7 @@ class DialogAddAutomationElement
|
||||
Object.entries(grp).map(([key, options]) =>
|
||||
options.members
|
||||
? flattenGroups(options.members)
|
||||
: options.domains
|
||||
? // domain elements are appended below from the backend descriptions
|
||||
[]
|
||||
: this._convertToItem(key, options, type, localize)
|
||||
: this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
|
||||
const items = flattenGroups(groups).flat();
|
||||
@@ -1083,8 +1059,6 @@ class DialogAddAutomationElement
|
||||
let genericCollectionIndex = -1;
|
||||
let dynamicCollectionIndex = -1;
|
||||
|
||||
const exclusiveDomains = this._getExclusiveDomains(type);
|
||||
|
||||
collections.forEach((collection, index) => {
|
||||
let collectionGroups = Object.entries(collection.groups);
|
||||
const groups: AddAutomationElementListItem[] = [];
|
||||
@@ -1115,8 +1089,7 @@ class DialogAddAutomationElement
|
||||
triggerDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
types
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1135,8 +1108,7 @@ class DialogAddAutomationElement
|
||||
conditionDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
types,
|
||||
exclusiveDomains
|
||||
types
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1169,19 +1141,9 @@ class DialogAddAutomationElement
|
||||
}
|
||||
|
||||
groups.push(
|
||||
...collectionGroups
|
||||
.filter(([, options]) =>
|
||||
this._groupHasItems(
|
||||
type,
|
||||
options,
|
||||
type === "condition"
|
||||
? conditionDescriptions
|
||||
: triggerDescriptions
|
||||
)
|
||||
)
|
||||
.map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
)
|
||||
...collectionGroups.map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
)
|
||||
);
|
||||
|
||||
if (groups.length) {
|
||||
@@ -1278,28 +1240,11 @@ class DialogAddAutomationElement
|
||||
return this._services(localize, services, manifests, group);
|
||||
}
|
||||
|
||||
const groupDef =
|
||||
TYPES[type].collections[collectionIndex]?.groups[group] ??
|
||||
TYPES[type].collections.find((collection) => group in collection.groups)
|
||||
?.groups[group];
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
|
||||
let result: AddAutomationElementListItem[];
|
||||
|
||||
if (groupDef?.domains && !groupDef.members) {
|
||||
// Curated group whose items come solely from backend domains (e.g. Sun).
|
||||
result = this._getDomainElementItems(type, groupDef.domains, localize);
|
||||
} else {
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
result = Object.entries(groups).map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
if (groupDef?.domains) {
|
||||
// Curated group with both static members and backend domains (Time).
|
||||
result.push(
|
||||
...this._getDomainElementItems(type, groupDef.domains, localize)
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = Object.entries(groups).map(([key, options]) =>
|
||||
this._convertToItem(key, options, type, localize)
|
||||
);
|
||||
|
||||
if (type === "action") {
|
||||
if (!this._selectedGroup) {
|
||||
@@ -1419,8 +1364,7 @@ class DialogAddAutomationElement
|
||||
triggers: TriggerDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
types: CollectionGroupType[]
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!triggers || !manifests) {
|
||||
return [];
|
||||
@@ -1430,7 +1374,7 @@ class DialogAddAutomationElement
|
||||
Object.keys(triggers).forEach((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
@@ -1492,8 +1436,7 @@ class DialogAddAutomationElement
|
||||
conditions: ConditionDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
types: CollectionGroupType[],
|
||||
exclusiveDomains: Set<string>
|
||||
types: CollectionGroupType[]
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!conditions || !manifests) {
|
||||
return [];
|
||||
@@ -1503,7 +1446,7 @@ class DialogAddAutomationElement
|
||||
Object.keys(conditions).forEach((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
@@ -1763,93 +1706,22 @@ class DialogAddAutomationElement
|
||||
options,
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
localize: LocalizeFunc
|
||||
): AddAutomationElementListItem => {
|
||||
// A group either lists explicit members or bundles backend element domains.
|
||||
const isGroup = !!(options.members || options.domains);
|
||||
return {
|
||||
key,
|
||||
name: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
isGroup ? "groups" : "type"
|
||||
}.${key}.label`
|
||||
),
|
||||
description: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
isGroup ? "groups" : "type"
|
||||
}.${key}.description${isGroup ? "" : ".picker"}`
|
||||
),
|
||||
iconPath: options.icon || TYPES[type].icons[key],
|
||||
};
|
||||
};
|
||||
|
||||
// Domains owned exclusively by a curated group, i.e. a group that bundles
|
||||
// only domains and no static members (e.g. "sun" under the Sun group). Those
|
||||
// are hidden from the generic dynamic domain grouping so they don't appear
|
||||
// both standalone and inside the curated group. Domains of a mixed group
|
||||
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
|
||||
// hidden — they still surface as their own domain group as well.
|
||||
private _getExclusiveDomains = memoizeOne(
|
||||
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
|
||||
const domains = new Set<string>();
|
||||
TYPES[type].collections.forEach((collection) =>
|
||||
Object.values(collection.groups).forEach((group) => {
|
||||
if (group.domains && !group.members) {
|
||||
group.domains.forEach((domain) => domains.add(domain));
|
||||
}
|
||||
})
|
||||
);
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDomainElementItems(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
domains: string[],
|
||||
localize: LocalizeFunc
|
||||
): AddAutomationElementListItem[] {
|
||||
const domainSet = new Set(domains);
|
||||
if (type === "trigger") {
|
||||
return Object.keys(this._triggerDescriptions)
|
||||
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
|
||||
.map((trigger) =>
|
||||
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
|
||||
);
|
||||
}
|
||||
if (type === "condition") {
|
||||
return Object.keys(this._conditionDescriptions)
|
||||
.filter((condition) => domainSet.has(getConditionDomain(condition)))
|
||||
.map((condition) =>
|
||||
this._getConditionListItem(
|
||||
localize,
|
||||
getConditionDomain(condition),
|
||||
condition
|
||||
)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private _groupHasItems(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
options: { members?: object; domains?: string[] },
|
||||
descriptions: TriggerDescriptions | ConditionDescriptions
|
||||
): boolean {
|
||||
if (options.members && Object.keys(options.members).length) {
|
||||
return true;
|
||||
}
|
||||
if (options.domains) {
|
||||
const domainSet = new Set(options.domains);
|
||||
const getDomain =
|
||||
type === "condition" ? getConditionDomain : getTriggerDomain;
|
||||
return Object.keys(descriptions).some((key) =>
|
||||
domainSet.has(getDomain(key))
|
||||
);
|
||||
}
|
||||
// plain single-element group
|
||||
return true;
|
||||
}
|
||||
): AddAutomationElementListItem => ({
|
||||
key,
|
||||
name: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
options.members ? "groups" : "type"
|
||||
}.${key}.label`
|
||||
),
|
||||
description: localize(
|
||||
// @ts-ignore
|
||||
`ui.panel.config.automation.editor.${type}s.${
|
||||
options.members ? "groups" : "type"
|
||||
}.${key}.description${options.members ? "" : ".picker"}`
|
||||
),
|
||||
iconPath: options.icon || TYPES[type].icons[key],
|
||||
});
|
||||
|
||||
private _getDomainGroupedListItems(
|
||||
localize: LocalizeFunc,
|
||||
@@ -2093,8 +1965,6 @@ class DialogAddAutomationElement
|
||||
) => {
|
||||
this._targetItems = undefined;
|
||||
this._loadItemsError = false;
|
||||
this._selectedGroup = undefined;
|
||||
this._selectedCollectionIndex = undefined;
|
||||
this._selectedTarget = ev.detail.value;
|
||||
mainWindow.history.pushState(
|
||||
{
|
||||
@@ -2116,67 +1986,6 @@ class DialogAddAutomationElement
|
||||
this._getItemsByTarget();
|
||||
};
|
||||
|
||||
// Time & location groups have no target; picking one drills into its items
|
||||
// (the same list as the matching group in the "by type" tab).
|
||||
private _handleTimeLocationGroupSelected = (
|
||||
ev: ValueChangedEvent<string>
|
||||
) => {
|
||||
this._targetItems = undefined;
|
||||
this._loadItemsError = false;
|
||||
this._selectedTarget = undefined;
|
||||
this._selectedGroup = ev.detail.value;
|
||||
this._selectedCollectionIndex = 0;
|
||||
mainWindow.history.pushState(
|
||||
{
|
||||
dialogData: {
|
||||
group: this._selectedGroup,
|
||||
collectionIndex: this._selectedCollectionIndex,
|
||||
},
|
||||
},
|
||||
""
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (this._narrow) {
|
||||
this._contentElement?.scrollTo(0, 0);
|
||||
} else {
|
||||
this._itemsListElement?.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _getTimeLocationLabel(
|
||||
type: AddAutomationElementDialogParams["type"]
|
||||
): string | undefined {
|
||||
if (type !== "trigger" && type !== "condition") {
|
||||
return undefined;
|
||||
}
|
||||
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
|
||||
}
|
||||
|
||||
private _getTimeLocationGroups = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
localize: LocalizeFunc,
|
||||
descriptions: TriggerDescriptions | ConditionDescriptions
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (type !== "trigger" && type !== "condition") {
|
||||
return [];
|
||||
}
|
||||
return TIME_LOCATION_GROUPS.map(
|
||||
(group) => [group, TYPES[type].collections[0].groups[group]] as const
|
||||
)
|
||||
.filter(
|
||||
([, options]) =>
|
||||
options && this._groupHasItems(type, options, descriptions)
|
||||
)
|
||||
.map(([group, options]) =>
|
||||
this._convertToItem(group, options, type, localize)
|
||||
)
|
||||
.filter((item) => item.name);
|
||||
}
|
||||
);
|
||||
|
||||
private _getDefaultStateItems(
|
||||
type: "trigger" | "condition"
|
||||
): AddAutomationElementListItem[] {
|
||||
|
||||
+7
-84
@@ -63,7 +63,6 @@ import {
|
||||
} from "../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
|
||||
|
||||
interface Level1Entries {
|
||||
open: boolean;
|
||||
@@ -94,16 +93,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public manifests?: DomainManifestLookup;
|
||||
|
||||
// Section title + group rows (Time, Location) for the targetless element
|
||||
// groups. Picking a row drills into that group's items, just like selecting
|
||||
// the matching group in the "by type" tab.
|
||||
@property({ attribute: false }) public timeLocationLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public timeLocationGroups?: AddAutomationElementListItem[];
|
||||
|
||||
@property({ attribute: false }) public selectedGroup?: string;
|
||||
|
||||
// #endregion properties
|
||||
|
||||
// #region context
|
||||
@@ -193,20 +182,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
? this._renderNarrow(this._entries, this.value)
|
||||
: html`
|
||||
${this._renderFloors(this.narrow, this._entries, this.value)}
|
||||
${this._renderTimeLocation(
|
||||
this.narrow,
|
||||
this.timeLocationLabel,
|
||||
this.timeLocationGroups,
|
||||
this.selectedGroup
|
||||
)}
|
||||
${this._renderUnassigned(this.narrow, this._entries, this.value)}
|
||||
${this._renderLabels(
|
||||
this.narrow,
|
||||
this.states,
|
||||
this._registries,
|
||||
this._labelRegistry,
|
||||
this.value
|
||||
)}
|
||||
${this._renderLabels(this.narrow, this.value)}
|
||||
`}
|
||||
${this.narrow && this._showShowMoreButton && !this._fullHeight
|
||||
? html`
|
||||
@@ -366,58 +343,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _renderTimeLocation = memoizeOne(
|
||||
(
|
||||
narrow: boolean,
|
||||
label?: string,
|
||||
groups?: AddAutomationElementListItem[],
|
||||
selectedGroup?: string
|
||||
) => {
|
||||
if (!label || !groups?.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-section-title>${label}</ha-section-title>
|
||||
<ha-list-base>
|
||||
${groups.map(
|
||||
(group) =>
|
||||
html`<ha-list-item-button
|
||||
.value=${group.key}
|
||||
@click=${this._selectTimeLocationGroup}
|
||||
class=${group.key === selectedGroup ? "selected" : ""}
|
||||
>
|
||||
${group.icon
|
||||
? html`<span slot="start">${group.icon}</span>`
|
||||
: group.iconPath
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${group.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<div slot="headline">${group.name}</div>
|
||||
${narrow
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: nothing}
|
||||
</ha-list-item-button>`
|
||||
)}
|
||||
</ha-list-base>`;
|
||||
}
|
||||
);
|
||||
|
||||
private _renderLabels = memoizeOne(
|
||||
(
|
||||
narrow: boolean,
|
||||
states: ContextType<typeof statesContext>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
labelRegistry: LabelRegistryEntry[],
|
||||
value?: SingleHassServiceTarget
|
||||
) => {
|
||||
(narrow: boolean, value?: SingleHassServiceTarget) => {
|
||||
const labels = this._getLabelsMemoized(
|
||||
states,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
labelRegistry,
|
||||
this.states,
|
||||
this._registries.areas,
|
||||
this._registries.devices,
|
||||
this._registries.entities,
|
||||
this._labelRegistry,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1240,13 +1173,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _selectTimeLocationGroup(ev: CustomEvent) {
|
||||
const value = (ev.currentTarget as any).value;
|
||||
if (value) {
|
||||
fireEvent(this, "time-location-group-selected", { value });
|
||||
}
|
||||
}
|
||||
|
||||
private async _valueChanged(itemId: string, expand = false) {
|
||||
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
|
||||
|
||||
@@ -1586,7 +1512,4 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-add-from-target": HaAutomationAddFromTarget;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"time-location-group-selected": { value: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-tooltip";
|
||||
import type {
|
||||
ForDict,
|
||||
PlatformCondition,
|
||||
} from "../../../../../data/automation";
|
||||
import type { PlatformCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
getConditionDomain,
|
||||
getConditionObjectId,
|
||||
@@ -23,21 +15,11 @@ import {
|
||||
} from "../../../../../data/condition";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import { getRecorderEntityOptions } from "../../../../../data/recorder";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import {
|
||||
extractFromTarget,
|
||||
getTargetEntityCount,
|
||||
} from "../../../../../data/target";
|
||||
import { getTargetEntityCount } from "../../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
|
||||
// when a condition has a `for:` duration, the recorder is only queried this far
|
||||
// back to prime it at setup, so longer durations can't be fully satisfied from
|
||||
// history after a restart or reload.
|
||||
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
|
||||
|
||||
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
|
||||
field.selector &&
|
||||
!field.required &&
|
||||
@@ -59,11 +41,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
|
||||
@state() private _resolvedTargetEntityCount?: number;
|
||||
|
||||
@state() private _targetHasUnrecordedEntity = false;
|
||||
|
||||
// Incremented on each recording check so stale async responses are ignored.
|
||||
private _recordingCheckId = 0;
|
||||
|
||||
public static get defaultConfig(): PlatformCondition {
|
||||
return { condition: "" };
|
||||
}
|
||||
@@ -74,26 +51,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
|
||||
// The `for:` priming info depends on both the condition (target + duration)
|
||||
// and the description (whether the condition targets entities at all), which
|
||||
// can arrive in separate updates.
|
||||
if (
|
||||
changedProperties.has("condition") ||
|
||||
changedProperties.has("description")
|
||||
) {
|
||||
const previousCondition = changedProperties.get("condition") as
|
||||
| undefined
|
||||
| this["condition"];
|
||||
if (
|
||||
changedProperties.has("description") ||
|
||||
previousCondition?.target !== this.condition?.target ||
|
||||
previousCondition?.options?.for !== this.condition?.options?.for
|
||||
) {
|
||||
this._updateDurationPrimingInfo();
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedProperties.has("condition")) {
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +263,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
|
||||
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
|
||||
) || fieldName}</span
|
||||
>
|
||||
${description
|
||||
? html`<span
|
||||
@@ -515,118 +472,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Shows a small info icon beside the `for` duration field's label, with a
|
||||
// tooltip explaining when history priming can't fully cover the duration.
|
||||
private _renderForPrimingInfo(fieldName: string) {
|
||||
if (fieldName !== "for") {
|
||||
return nothing;
|
||||
}
|
||||
const text = this._durationPrimingInfoText();
|
||||
if (!text) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
id="for-priming-info"
|
||||
tabindex="0"
|
||||
class="priming-info-icon"
|
||||
.path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
|
||||
}
|
||||
|
||||
private _durationPrimingInfoText(): string | undefined {
|
||||
const forValue = this.condition.options?.for;
|
||||
|
||||
// Priming only happens for entity conditions that have a `for:` duration.
|
||||
if (
|
||||
forValue === undefined ||
|
||||
forValue === "" ||
|
||||
!this.description?.target
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this._targetHasUnrecordedEntity) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
|
||||
);
|
||||
}
|
||||
|
||||
if (this._durationExceedsLookback(forValue)) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
|
||||
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _durationExceedsLookback(forValue: unknown): boolean {
|
||||
const duration = createDurationData(
|
||||
forValue as string | number | ForDict | undefined
|
||||
);
|
||||
if (!duration) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
durationDataToSeconds(duration) >
|
||||
MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600
|
||||
);
|
||||
}
|
||||
|
||||
private async _updateDurationPrimingInfo(): Promise<void> {
|
||||
const forValue = this.condition.options?.for;
|
||||
const target = this.condition.target;
|
||||
|
||||
// Recording status only matters for an entity condition that has both a
|
||||
// target and a `for:` duration.
|
||||
const checkId = ++this._recordingCheckId;
|
||||
if (
|
||||
forValue === undefined ||
|
||||
forValue === "" ||
|
||||
!this.description?.target ||
|
||||
!target ||
|
||||
!this.hass.config.components.includes("recorder")
|
||||
) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { referenced_entities } = await extractFromTarget(
|
||||
this.hass.callWS,
|
||||
target
|
||||
);
|
||||
// Ignore if a newer check superseded this one.
|
||||
if (checkId !== this._recordingCheckId) {
|
||||
return;
|
||||
}
|
||||
if (!referenced_entities.length) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
return;
|
||||
}
|
||||
const recordingDisabled = await Promise.all(
|
||||
referenced_entities.map((entityId) =>
|
||||
getRecorderEntityOptions(this.hass, entityId)
|
||||
.then((options) => options.recording_disabled_by !== null)
|
||||
// Unknown entity or command unavailable on older cores: don't warn.
|
||||
.catch(() => false)
|
||||
)
|
||||
);
|
||||
if (checkId !== this._recordingCheckId) {
|
||||
return;
|
||||
}
|
||||
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
|
||||
} catch (_err) {
|
||||
// Target resolution failed; fall back to no warning rather than guessing.
|
||||
if (checkId === this._recordingCheckId) {
|
||||
this._targetHasUnrecordedEntity = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -682,15 +527,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.priming-info-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--warning-color);
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { load } from "js-yaml";
|
||||
import { load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
|
||||
let loaded: any;
|
||||
try {
|
||||
loaded = load(paste);
|
||||
loaded = load(paste, { schema: YAML11_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { load } from "js-yaml";
|
||||
import { load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, queryAll } from "lit/decorators";
|
||||
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
|
||||
|
||||
let loaded: any;
|
||||
try {
|
||||
loaded = load(paste);
|
||||
loaded = load(paste, { schema: YAML11_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
|
||||
@@ -27,7 +27,6 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -473,22 +472,6 @@ class HUIRoot extends LitElement {
|
||||
const title_only = !icon_only && !icon_and_title;
|
||||
const hidden =
|
||||
!this._editMode && (view.subview || _isTabHiddenForUser(view));
|
||||
const tabContent = html`
|
||||
${icon_only || icon_and_title
|
||||
? html`<ha-icon
|
||||
class=${classMap({
|
||||
"child-view-icon": Boolean(view.subview),
|
||||
})}
|
||||
title=${ifDefined(view.title)}
|
||||
.icon=${view.icon}
|
||||
></ha-icon>`
|
||||
: nothing}
|
||||
${icon_and_title ? view.title : nothing}
|
||||
${title_only
|
||||
? view.title ||
|
||||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
|
||||
: nothing}
|
||||
`;
|
||||
return html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
@@ -496,9 +479,6 @@ class HUIRoot extends LitElement {
|
||||
.active=${this._curView === index}
|
||||
.disabled=${hidden}
|
||||
aria-label=${ifDefined(view.title)}
|
||||
data-path=${view.path || index}
|
||||
@auxclick=${this._handleViewTabNewTabClick}
|
||||
@click=${this._handleViewTabNewTabClick}
|
||||
class=${classMap({
|
||||
"icon-only": Boolean(icon_only),
|
||||
"icon-and-title": Boolean(icon_and_title),
|
||||
@@ -515,7 +495,24 @@ class HUIRoot extends LitElement {
|
||||
@click=${this._moveViewLeft}
|
||||
.disabled=${this._curView === 0}
|
||||
></ha-icon-button-arrow-prev>
|
||||
${tabContent}
|
||||
`
|
||||
: nothing}
|
||||
${icon_only || icon_and_title
|
||||
? html`<ha-icon
|
||||
class=${classMap({
|
||||
"child-view-icon": Boolean(view.subview),
|
||||
})}
|
||||
title=${ifDefined(view.title)}
|
||||
.icon=${view.icon}
|
||||
></ha-icon>`
|
||||
: nothing}
|
||||
${icon_and_title ? view.title : nothing}
|
||||
${title_only
|
||||
? view.title ||
|
||||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
|
||||
: nothing}
|
||||
${this._editMode
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.title=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.edit"
|
||||
@@ -533,7 +530,7 @@ class HUIRoot extends LitElement {
|
||||
.disabled=${(this._curView! as number) + 1 === views.length}
|
||||
></ha-icon-button-arrow-next>
|
||||
`
|
||||
: tabContent}
|
||||
: nothing}
|
||||
</ha-tab-group-tab>
|
||||
`;
|
||||
})}
|
||||
@@ -574,8 +571,7 @@ class HUIRoot extends LitElement {
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="navigationIcon"
|
||||
.href=${this._backPath}
|
||||
@click=${this._handleBackClick}
|
||||
@click=${this._goBack}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
@@ -886,27 +882,6 @@ class HUIRoot extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBackClick(ev: MouseEvent): void {
|
||||
if (this._backPath && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
this._goBack();
|
||||
}
|
||||
|
||||
private get _backPath(): string | undefined {
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
if (curViewConfig?.back_path != null) {
|
||||
return curViewConfig.back_path;
|
||||
}
|
||||
if (this.backPath) {
|
||||
return this.backPath;
|
||||
}
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
|
||||
private _addDevice = async () => {
|
||||
await this.hass.loadFragmentTranslation("config");
|
||||
showAddIntegrationDialog(this, { navigateToResult: true });
|
||||
@@ -1096,7 +1071,9 @@ class HUIRoot extends LitElement {
|
||||
}
|
||||
|
||||
private _navigateToView(path: string | number, replace?: boolean) {
|
||||
const url = this._viewUrl(path);
|
||||
const url = this.lovelace!.editMode
|
||||
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
|
||||
: `${this.route!.prefix}/${path}${location.search}`;
|
||||
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
if (currentUrl !== url) {
|
||||
@@ -1104,30 +1081,6 @@ class HUIRoot extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _viewUrl(path: string | number): string {
|
||||
return this.lovelace!.editMode
|
||||
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
|
||||
: `${this.route!.prefix}/${path}${location.search}`;
|
||||
}
|
||||
|
||||
private _handleViewTabNewTabClick(ev: MouseEvent): void {
|
||||
if (
|
||||
this._editMode ||
|
||||
(ev.button !== 1 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const tab = ev.currentTarget as HTMLElement;
|
||||
const path = tab.dataset.path;
|
||||
if (path) {
|
||||
window.open(this._viewUrl(path), "_blank", "noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
private _editView() {
|
||||
showEditViewDialog(this, {
|
||||
lovelace: this.lovelace!,
|
||||
|
||||
@@ -5107,7 +5107,6 @@
|
||||
"select_target": "Select a target",
|
||||
"home": "Home",
|
||||
"unassigned": "Unassigned",
|
||||
"time_sun": "Time and sun",
|
||||
"blocks": "Blocks",
|
||||
"tabs": {
|
||||
"target": "By target",
|
||||
@@ -5183,11 +5182,8 @@
|
||||
"entity": {
|
||||
"label": "Entity"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time"
|
||||
},
|
||||
"sun": {
|
||||
"label": "Sun"
|
||||
"time_location": {
|
||||
"label": "Time and location"
|
||||
},
|
||||
"generic": {
|
||||
"label": "Generic"
|
||||
@@ -5440,10 +5436,6 @@
|
||||
"invalid_condition": "Invalid condition configuration",
|
||||
"validation_failed": "Condition validation failed",
|
||||
"test_failed": "Error occurred while testing condition",
|
||||
"duration_priming": {
|
||||
"entity_not_recorded": "One or more of the selected entities aren''t being recorded, so their history can''t be used. After a restart or reload, this condition only becomes true once they''ve been in the matching state for the full duration.",
|
||||
"history_capped": "Only the last {hours} hours of history are checked. For longer durations, after a restart or reload this condition only becomes true once the entities have been in the matching state for the full duration."
|
||||
},
|
||||
"duplicate": "[%key:ui::common::duplicate%]",
|
||||
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
|
||||
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
|
||||
@@ -5468,11 +5460,8 @@
|
||||
"entity": {
|
||||
"label": "Entity"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time"
|
||||
},
|
||||
"sun": {
|
||||
"label": "Sun"
|
||||
"time_location": {
|
||||
"label": "Time and location"
|
||||
},
|
||||
"generic": {
|
||||
"label": "Generic"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import durationToSeconds, {
|
||||
durationDataToSeconds,
|
||||
} from "../../../src/common/datetime/duration_to_seconds";
|
||||
import durationToSeconds from "../../../src/common/datetime/duration_to_seconds";
|
||||
|
||||
describe("durationToSeconds", () => {
|
||||
it("works", () => {
|
||||
@@ -10,23 +8,3 @@ describe("durationToSeconds", () => {
|
||||
assert.strictEqual(durationToSeconds("11:01:05"), 39665);
|
||||
});
|
||||
});
|
||||
|
||||
describe("durationDataToSeconds", () => {
|
||||
it("sums all duration fields", () => {
|
||||
assert.strictEqual(
|
||||
durationDataToSeconds({
|
||||
days: 1,
|
||||
hours: 2,
|
||||
minutes: 3,
|
||||
seconds: 4,
|
||||
milliseconds: 500,
|
||||
}),
|
||||
93784.5
|
||||
);
|
||||
});
|
||||
|
||||
it("treats missing fields as zero", () => {
|
||||
assert.strictEqual(durationDataToSeconds({}), 0);
|
||||
assert.strictEqual(durationDataToSeconds({ hours: 6 }), 21600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,9 +66,7 @@ export class HaTest 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(
|
||||
|
||||
@@ -54,16 +54,29 @@ async function assertPageLoads(page: Page, hash: string, selector: string) {
|
||||
}
|
||||
|
||||
// Errors that are gallery-harness artifacts rather than bugs in the component
|
||||
// under test. The Lit-context init-error family that used to live here is gone:
|
||||
// ha-gallery now provides fallback contexts for every demo (mirroring the real
|
||||
// app's root), so context-consuming components resolve `localize`, formatters,
|
||||
// config, etc. synchronously instead of throwing during init.
|
||||
// under test. The gallery feeds demos a synchronous mock `hass`, but migrated
|
||||
// components now read `localize`/formatters from Lit context, which resolves
|
||||
// asynchronously — so a demo can render one frame before the context lands and
|
||||
// throw a transient "undefined" error during init. These don't prevent the
|
||||
// demo from rendering (the toBeAttached check above still has to pass), and
|
||||
// they're timing-dependent, so we filter the whole init-error family here.
|
||||
const IGNORED_ERRORS: RegExp[] = [
|
||||
/ResizeObserver/,
|
||||
/Non-Error/,
|
||||
/Extension context/,
|
||||
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
|
||||
/^Object$/,
|
||||
// localize consumed from context before it resolves (`this.localize` /
|
||||
// `this._localize is not a function`, or `hass.localize` read as undefined).
|
||||
/_?localize is not a function/,
|
||||
/Cannot read properties of undefined \(reading 'localize'\)/,
|
||||
// Formatters consumed from context before it resolves.
|
||||
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
|
||||
// hass API methods consumed from context before it resolves (e.g. the
|
||||
// update demo fetches release notes via callWS during init).
|
||||
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
|
||||
// locale fields read before the mock locale is wired up.
|
||||
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
|
||||
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
|
||||
// from mock state data — pre-existing gallery data issue.
|
||||
/Cannot read properties of undefined \(reading 'some'\)/,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds and posts a PR comment summarising Playwright E2E failures from the
|
||||
// merged JSON report. Invoked from the `report` job in .github/workflows/e2e.yaml
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: postReportComment } =
|
||||
// await import("${{ github.workspace }}/test/e2e/post-report-comment.mjs");
|
||||
// await postReportComment({ github, context, core });
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const REPORT_PATH = "test/e2e/reports/combined/results.json";
|
||||
|
||||
// GitHub comment bodies cap at 65536 chars; leave headroom.
|
||||
const MAX_BODY = 60000;
|
||||
|
||||
// Strip ANSI colour codes that Playwright bakes into error messages.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
|
||||
// Walk the JSON report tree and collect every failing spec with its error
|
||||
// output, so the comment shows the actual test failures.
|
||||
const collectFailures = (report) => {
|
||||
const failures = [];
|
||||
|
||||
const walk = (suite, titlePath) => {
|
||||
const here = suite.title ? [...titlePath, suite.title] : titlePath;
|
||||
for (const spec of suite.specs ?? []) {
|
||||
if (spec.ok) continue;
|
||||
const errors = [];
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const result of test.results ?? []) {
|
||||
for (const err of result.errors ?? []) {
|
||||
if (err.message) errors.push(stripAnsi(err.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
failures.push({
|
||||
title: [...here, spec.title].join(" › "),
|
||||
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
for (const child of suite.suites ?? []) walk(child, here);
|
||||
};
|
||||
|
||||
for (const suite of report.suites ?? []) walk(suite, []);
|
||||
return failures;
|
||||
};
|
||||
|
||||
const formatFailure = (failure) => {
|
||||
const output =
|
||||
failure.errors.join("\n\n").trim() || "(no error output captured)";
|
||||
return [
|
||||
`<details><summary>❌ ${failure.title} <code>${failure.location}</code></summary>`,
|
||||
"",
|
||||
"```ts",
|
||||
output,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
export default async function postReportComment({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
|
||||
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
|
||||
let failures = [];
|
||||
|
||||
try {
|
||||
const report = JSON.parse(readFileSync(REPORT_PATH, "utf8"));
|
||||
stats = report.stats ?? stats;
|
||||
failures = collectFailures(report);
|
||||
} catch (err) {
|
||||
core.warning(`Could not parse Playwright JSON report: ${err.message}`);
|
||||
}
|
||||
|
||||
const summaryLine =
|
||||
`**${stats.unexpected} failed**, ${stats.expected} passed` +
|
||||
(stats.flaky ? `, ${stats.flaky} flaky` : "") +
|
||||
(stats.skipped ? `, ${stats.skipped} skipped` : "");
|
||||
|
||||
const details = failures.length
|
||||
? failures.map(formatFailure).join("\n")
|
||||
: "_No failing tests were captured in the report._";
|
||||
|
||||
let body = [
|
||||
"## Playwright E2E tests failed",
|
||||
"",
|
||||
summaryLine,
|
||||
"",
|
||||
details,
|
||||
"",
|
||||
"The combined HTML report is available as a workflow artifact.",
|
||||
"",
|
||||
`[View workflow run](${runUrl})`,
|
||||
].join("\n");
|
||||
|
||||
if (body.length > MAX_BODY) {
|
||||
body = `${body.slice(0, MAX_BODY)}\n\n_…report truncated, see the full HTML report artifact._`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -4194,14 +4194,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "@playwright/test@npm:1.61.0"
|
||||
"@playwright/test@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "@playwright/test@npm:1.60.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.61.0"
|
||||
playwright: "npm:1.60.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/359a9a4a59a87361d416818966bb810a38970d9f2b8364349d22099fb23eb043530714374a83010f9f3471c7e758df43c49bf32128745af13277adfb211aa752
|
||||
checksum: 10/a13d369014e1934b0aa484c5d59537f5249af0fe006ac4ecbcbe14c673221412706193ea2d9cf3b2c0cc69e3ddbb4daddb006f0bedfdeb05f687776ed8c35f5f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5550,13 +5550,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/js-yaml@npm:4.0.9":
|
||||
version: 4.0.9
|
||||
resolution: "@types/js-yaml@npm:4.0.9"
|
||||
checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/jsesc@npm:^2.5.0":
|
||||
version: 2.5.1
|
||||
resolution: "@types/jsesc@npm:2.5.1"
|
||||
@@ -9748,7 +9741,7 @@ __metadata:
|
||||
"@octokit/auth-oauth-device": "npm:8.0.3"
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.61.0"
|
||||
"@playwright/test": "npm:1.60.0"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.5.15"
|
||||
"@rspack/core": "npm:2.0.8"
|
||||
@@ -9763,7 +9756,6 @@ __metadata:
|
||||
"@types/color-name": "npm:2.0.0"
|
||||
"@types/culori": "npm:4.0.1"
|
||||
"@types/html-minifier-terser": "npm:7.0.2"
|
||||
"@types/js-yaml": "npm:4.0.9"
|
||||
"@types/leaflet": "npm:1.9.21"
|
||||
"@types/leaflet-draw": "npm:1.0.13"
|
||||
"@types/leaflet.markercluster": "npm:1.5.6"
|
||||
@@ -9819,7 +9811,7 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.2.5"
|
||||
intl-messageformat: "npm:11.2.8"
|
||||
js-yaml: "npm:4.2.0"
|
||||
js-yaml: "npm:5.0.0"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
@@ -10762,7 +10754,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:4.2.0, js-yaml@npm:^4.1.0":
|
||||
"js-yaml@npm:5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "js-yaml@npm:5.0.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/51691da5a1b3894ffdd6ffe386d061fecf8769d135560dce8f3d3b7c5f7b167ec7fbe2c232c91135d58216b2f6ba215e85ac60fe569c46f0a9024f2ff6dec47e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.0":
|
||||
version: 4.2.0
|
||||
resolution: "js-yaml@npm:4.2.0"
|
||||
dependencies:
|
||||
@@ -12600,27 +12603,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "playwright-core@npm:1.61.0"
|
||||
"playwright-core@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "playwright-core@npm:1.60.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/e7ac4b2ef1c5f1701ec8086e47bc341988a3f753009d450e2a62daab5ade1fa943f1ef7fb48c721618dd7bd42667a11ff73c2e5dbd0674c20a3397b3c5be2f61
|
||||
checksum: 10/66c0f83d627e673261c848dd6fe1f2856d5b5b6859268acb61a45c00f5bf926e596a351a9c481a3a4e82a45022c7c6b5d99ebc3906fc6876ac582ed6f7e16190
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.61.0":
|
||||
version: 1.61.0
|
||||
resolution: "playwright@npm:1.61.0"
|
||||
"playwright@npm:1.60.0":
|
||||
version: 1.60.0
|
||||
resolution: "playwright@npm:1.60.0"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.61.0"
|
||||
playwright-core: "npm:1.60.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/b5faf97391315334a30e88e03216fd6090988b99896e71b341995a27c49d49dcebc825ec389dabc9b2d2cab6368c418cad9cbb925a88de35fb3b26a19bf05bea
|
||||
checksum: 10/8569770637ee35d08cca3b53a5b56c21e9236bd1ac4718456d5988fb8acd51c5b3cc2cf90748363a36199529e870a9b6c68d6fc9e19571261cd867005d6331c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user