Compare commits

..

4 Commits

Author SHA1 Message Date
Wendelin 8af5908682 Fix add T/C/A floor auto open; Target details adaptive dialog. (#52001)
* Auto open single floor

* Use adaptive dialog for target details

* review
2026-05-12 19:28:24 +03:00
George Caliment 60e95b886c Fixed how ha-entity-toggle sets ha-switch styles var (#51984) 2026-05-12 16:46:01 +02:00
Wendelin 0385ca8076 Add link to single integration entry warning (#51977)
* Add link to single integration entry warning

* Refactor single config entry warning: move function to dedicated file and update imports

* Implement single config entry warning dialog and update related functions

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-12 10:33:02 +00:00
Tom Carpenter 02c65fc8cb Position bars on statistics charts at centre of data point time range (#51957)
* Position statistics chart bars at centre of time range

When displaying 5minute or hourly data periods, position each bar at the midpoint of its start/end time. This mimics the behaviour in the various energy cards for consistency.

* Move limit comparison into pushData
Results in clearer function argument usage.

* Add time range for statistics-chart bar tooltip

When using hour/5minute periods the bars are recentred. Update the tooltips to show time range they cover.

* Omit time from tooltip for bars with periods of day or longer

Don't clutter the tooltip with unnecessary times of 0:00 when using day/month/year timescales on bar charts, just show the date range.

For week/month/year, we now also include the range of dates of the bar rather than just the start date.
2026-05-12 12:33:39 +03:00
15 changed files with 432 additions and 426 deletions
+102 -30
View File
@@ -13,7 +13,9 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDate } from "../../common/datetime/format_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import {
formatNumber,
getNumberFormatOptions,
@@ -241,6 +243,8 @@ export class StatisticsChart extends LitElement {
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const chartIsBar = this.chartType.startsWith("bar");
const period = this.period;
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
@@ -252,8 +256,67 @@ export class StatisticsChart extends LitElement {
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
@@ -265,14 +328,7 @@ export class StatisticsChart extends LitElement {
options
)}${unit}`;
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
@@ -511,33 +567,53 @@ export class StatisticsChart extends LitElement {
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: typeof legendData = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(this.period === "5minute" || this.period === "hour");
const pushData = (
start: Date,
end: Date,
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
if (start > end) {
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (
chartType === "line" &&
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
}
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
prevEndTime = limit;
};
let color = colors[statistic_id];
@@ -697,11 +773,7 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
pushData(startDate, endDate, endTime, dataValues);
}
});
+11 -3
View File
@@ -22,6 +22,14 @@ const isOn = (stateObj?: HassEntity) =>
!STATES_OFF.includes(stateObj.state) &&
!isUnavailableState(stateObj.state);
/**
* @element ha-entity-toggle
*
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
*/
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes
@@ -165,9 +173,9 @@ export class HaEntityToggle extends LitElement {
white-space: nowrap;
}
ha-switch {
--ha-switch-width: 38px;
--ha-switch-size: 20px;
--ha-switch-thumb-size: 14px;
--ha-switch-width: var(--ha-entity-toggle-switch-width, 38px);
--ha-switch-size: var(--ha-entity-toggle-switch-size, 20px);
--ha-switch-thumb-size: var(--ha-entity-toggle-switch-thumb-size, 14px);
}
ha-icon-button {
--ha-icon-button-size: 40px;
@@ -18,7 +18,7 @@ import {
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-dialog";
import "../../ha-adaptive-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
@@ -153,7 +153,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
!this._entitySourcesLoaded;
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
@@ -187,7 +187,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
`}
</ha-list-base>
</div>
</ha-dialog>
</ha-adaptive-dialog>
`;
}
@@ -1,7 +1,7 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import {
mdiCheckCircle,
mdiChip,
mdiCircleOffOutline,
mdiCursorDefaultClickOutline,
mdiDocker,
mdiExclamationThick,
@@ -17,10 +17,11 @@ import {
mdiNumeric6,
mdiNumeric7,
mdiNumeric8,
mdiPlayCircle,
mdiPound,
mdiShield,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -30,7 +31,6 @@ import { atLeastVersion } from "../../../../../common/config/version";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/chips/ha-assist-chip";
import "../../../../../components/chips/ha-chip-set";
@@ -79,9 +79,9 @@ import { bytesToString } from "../../../../../util/bytes-to-string";
import { getAppDisplayName } from "../../common/app";
import "../../components/supervisor-apps-card-content";
import "../components/supervisor-app-metric";
import "../components/supervisor-app-update-available-card";
import { extractChangelog } from "../util/supervisor-app";
import "./supervisor-app-system-managed";
import "../components/supervisor-app-update-available-card";
const STAGE_ICON = {
stable: mdiCheckCircle,
@@ -203,10 +203,28 @@ class SupervisorAppInfo extends LitElement {
: nothing}
<div class="addon-version light-color">
${this.addon.version
? html`<supervisor-apps-state
.state=${this.addon.state}
></supervisor-apps-state>`
: this.addon.version_latest}
? html`
${this._computeIsRunning
? html`
<ha-svg-icon
.title=${this.hass.localize(
"ui.panel.config.apps.dashboard.app_running"
)}
class="running"
.path=${mdiPlayCircle}
></ha-svg-icon>
`
: html`
<ha-svg-icon
.title=${this.hass.localize(
"ui.panel.config.apps.dashboard.app_stopped"
)}
class="stopped"
.path=${mdiCircleOffOutline}
></ha-svg-icon>
`}
`
: html` ${this.addon.version_latest} `}
</div>
</div>
<div class="description light-color">
@@ -819,7 +837,7 @@ class SupervisorAppInfo extends LitElement {
const id = ev.currentTarget.id as AddonCapability;
showAlertDialog(this, {
title: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.${id}.title` as LocalizeKeys
`ui.panel.config.apps.dashboard.capability.${id}.title`
),
text: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.${id}.description`
@@ -1,20 +1,11 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-svg-icon";
import type { AddonStage, AddonState } from "../../../../data/hassio/addon";
import type { AddonStage } from "../../../../data/hassio/addon";
import type { HomeAssistant } from "../../../../types";
import { getAppDisplayName } from "../common/app";
import "./supervisor-apps-state";
import "./supervisor-apps-tag";
export interface AppTag {
label: string;
variant: "brand" | "success" | "warning" | "danger" | "neutral";
iconPath?: string;
}
@customElement("supervisor-apps-card-content")
class SupervisorAppsCardContent extends LitElement {
@@ -25,13 +16,13 @@ class SupervisorAppsCardContent extends LitElement {
@property() public stage: AddonStage = "stable";
@property() public state: AddonState = null;
@property() public description?: string;
@property({ type: Boolean }) public available = true;
@property({ attribute: false }) public tags?: AppTag[];
@property({ attribute: false }) public showTopbar = false;
@property({ attribute: false }) public topbarClass?: string;
@property({ attribute: false }) public iconTitle?: string;
@@ -42,87 +33,78 @@ class SupervisorAppsCardContent extends LitElement {
@property({ attribute: false }) public iconImage?: string;
protected render(): TemplateResult {
return html`
<div class="app">
<div class="icon-wrapper">
${this.iconImage
? html`
<img
class="icon-image"
src=${this.iconImage}
.title=${this.iconTitle}
alt=${this.iconTitle ?? ""}
/>
`
: html`
<ha-svg-icon
class="app-icon"
.path=${this.icon}
.title=${this.iconTitle}
></ha-svg-icon>
`}
</div>
<div>
<div class="title-row">
<div class="title">
${getAppDisplayName(this.title, this.stage)}
</div>
</div>
<div class="addition">
${this.description}
${
/* treat as available when undefined */
this.available === false ? " (Not available)" : ""
}
</div>
</div>
</div>
${this.tags?.length || this.state
? html`
<div class="footer">
<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>
const stageLabel =
this.stage !== "stable"
? this.hass.localize(
`ui.panel.config.apps.dashboard.capability.stages.${this.stage}`
)
: undefined;
${this.tags?.length
? html`<div class="tags">
${this.tags.map(
(tag) =>
html`<supervisor-apps-tag
.variant=${tag.variant}
.iconPath=${tag.iconPath}
.label=${tag.label}
></supervisor-apps-tag>`
)}
</div>`
: nothing}
return html`
${this.showTopbar
? html` <div class="topbar ${this.topbarClass}"></div> `
: ""}
${this.iconImage
? html`
<div class="icon_image ${this.iconClass}">
<img
src=${this.iconImage}
.title=${this.iconTitle}
alt=${this.iconTitle ?? ""}
/>
<div></div>
</div>
`
: nothing}
: html`
<ha-svg-icon
class=${this.iconClass!}
.path=${this.icon}
.title=${this.iconTitle}
></ha-svg-icon>
`}
<div>
<div class="title-row">
<div class="title">${getAppDisplayName(this.title, this.stage)}</div>
${stageLabel
? html` <span class="stage ${this.stage}"> ${stageLabel} </span> `
: nothing}
</div>
<div class="addition">
${this.description}
${
/* treat as available when undefined */
this.available === false ? " (Not available)" : ""
}
</div>
</div>
`;
}
static styles = css`
.app {
margin-bottom: var(--ha-space-2);
gap: var(--ha-space-4);
display: flex;
:host {
direction: ltr;
}
.icon-wrapper {
position: relative;
margin-top: var(--ha-space-1);
width: 40px;
height: 40px;
flex-shrink: 0;
}
.app-icon {
ha-svg-icon {
margin-right: var(--ha-space-6);
margin-left: var(--ha-space-2);
margin-top: var(--ha-space-2);
margin-top: var(--ha-space-3);
float: left;
color: var(--secondary-text-color);
}
.icon-image {
max-height: 40px;
max-width: 40px;
ha-svg-icon.update {
color: var(--warning-color);
}
ha-svg-icon.running,
ha-svg-icon.installed {
color: var(--success-color);
}
ha-svg-icon.hassupdate,
ha-svg-icon.backup {
color: var(--state-icon-color);
}
ha-svg-icon.not_available {
color: var(--error-color);
}
.title {
flex: 1;
@@ -138,6 +120,22 @@ class SupervisorAppsCardContent extends LitElement {
gap: var(--ha-space-2);
min-width: 0;
}
.stage {
flex: none;
border-radius: 999px;
font-size: 12px;
line-height: 1;
padding: 4px 8px;
white-space: nowrap;
}
.stage.experimental {
color: var(--warning-color);
background-color: rgba(var(--rgb-warning-color), 0.12);
}
.stage.deprecated {
color: var(--error-color);
background-color: rgba(var(--rgb-error-color), 0.12);
}
.addition {
color: var(--secondary-text-color);
margin-top: var(--ha-space-1);
@@ -146,18 +144,43 @@ class SupervisorAppsCardContent extends LitElement {
height: 2.4em;
line-height: var(--ha-line-height-condensed);
}
.footer {
border-top: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
padding-top: var(--ha-space-2);
display: flex;
gap: var(--ha-space-2);
flex-wrap: wrap;
justify-content: space-between;
.icon_image img {
max-height: 40px;
max-width: 40px;
margin-top: var(--ha-space-1);
margin-right: var(--ha-space-4);
float: left;
}
.tags {
display: flex;
gap: var(--ha-space-2);
.icon_image.stopped,
.icon_image.not_available {
filter: grayscale(1);
}
.dot {
position: absolute;
background-color: var(--warning-color);
width: 12px;
height: 12px;
top: var(--ha-space-2);
right: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
}
.topbar {
position: absolute;
width: 100%;
height: 2px;
top: 0;
left: 0;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.topbar.installed {
background-color: var(--primary-color);
}
.topbar.update {
background-color: var(--accent-color);
}
.topbar.unavailable {
background-color: var(--error-color);
}
`;
}
@@ -1,72 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { internationalizationContext } from "../../../../data/context";
import type { AddonState } from "../../../../data/hassio/addon";
@customElement("supervisor-apps-state")
class SupervisorAppsState extends LitElement {
@property() public state: Exclude<AddonState, null> = "unknown";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render(): TemplateResult {
return html`
<div class="dot state-${this.state}"></div>
<span
>${this._i18n.localize(
`ui.panel.config.apps.dashboard.capability.state.${this.state}`
)}</span
>
`;
}
static styles = css`
:host {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.dot {
width: 8px;
height: 8px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--ha-color-on-neutral-normal);
flex-shrink: 0;
}
.dot.state-started {
background-color: var(--ha-color-on-success-normal);
animation: state-dot-pulse 1.8s infinite;
}
.dot.state-startup {
background-color: var(--ha-color-on-warning-normal);
}
.dot.state-error {
background-color: var(--ha-color-on-danger-normal);
}
@keyframes state-dot-pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--rgb-success-color), 0.6);
}
100% {
box-shadow: 0 0 0 6px rgba(var(--rgb-success-color), 0);
}
}
@media (prefers-reduced-motion) {
.dot.state-started {
animation: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"supervisor-apps-state": SupervisorAppsState;
}
}
@@ -1,64 +0,0 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-svg-icon";
@customElement("supervisor-apps-tag")
class SupervisorAppsTag extends LitElement {
@property() public variant:
| "brand"
| "success"
| "warning"
| "danger"
| "neutral" = "neutral";
@property({ attribute: "icon-path" }) public iconPath?: string;
@property() public label!: string;
protected render(): TemplateResult {
return html`<wa-tag .variant=${this.variant}>
${this.iconPath
? html`<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>`
: nothing}
${this.label}
</wa-tag>`;
}
static styles = css`
wa-tag {
font-size: var(--ha-font-size-xs);
border-radius: var(--ha-border-radius-pill);
height: 20px;
border: none;
padding-inline: var(--ha-space-1) var(--ha-space-2);
}
wa-tag ha-svg-icon {
--mdc-icon-size: 16px;
width: 16px;
height: 16px;
}
wa-tag[variant="success"] {
color: var(--ha-color-on-success-normal);
}
wa-tag[variant="warning"] {
color: var(--ha-color-on-warning-normal);
}
wa-tag[variant="danger"] {
color: var(--ha-color-on-error-normal);
}
wa-tag[variant="neutral"] {
color: var(--ha-color-on-neutral-normal);
}
wa-tag[variant="brand"] {
color: var(--ha-color-on-primary-normal);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"supervisor-apps-tag": SupervisorAppsTag;
}
}
@@ -1,8 +1,5 @@
import {
mdiAlertDecagramOutline,
mdiArrowUpBoldCircle,
mdiArrowUpBoldCircleOutline,
mdiFlask,
mdiPuzzle,
mdiRefresh,
mdiStorePlus,
@@ -32,9 +29,7 @@ import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { getAppDisplayName } from "./common/app";
import "./components/supervisor-apps-card-content";
import type { AppTag } from "./components/supervisor-apps-card-content";
import { supervisorAppsStyle } from "./resources/supervisor-apps-style";
@customElement("ha-config-apps-installed")
@@ -101,59 +96,65 @@ export class HaConfigAppsInstalled extends LitElement {
</ha-input-search>
</div>
<div class="content">
${addons.length === 0
? html`
<ha-card outlined>
<div class="card-content">
<button class="link" @click=${this._openStore}>
${this.hass.localize(
"ui.panel.config.apps.installed.no_apps"
)}
</button>
</div>
</ha-card>
`
: addons.map(
(addon) => html`
<ha-card
role="button"
tabindex="0"
outlined
.addon=${addon}
@click=${this._addonTapped}
aria-label=${getAppDisplayName(addon.name, addon.stage)}
>
<div class="card-group">
${addons.length === 0
? html`
<ha-card outlined>
<div class="card-content">
<supervisor-apps-card-content
.hass=${this.hass}
.title=${addon.name}
.stage=${addon.stage}
.description=${addon.description}
available
.tags=${this._getAppTags(addon)}
.state=${addon.state}
.icon=${addon.update_available
? mdiArrowUpBoldCircle
: mdiPuzzle}
.iconTitle=${addon.state !== "started"
? this.hass.localize(
"ui.panel.config.apps.installed.app_stopped"
)
: addon.update_available
? this.hass.localize(
"ui.panel.config.apps.installed.app_update_available"
)
: this.hass.localize(
"ui.panel.config.apps.installed.app_running"
)}
.iconImage=${addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined}
></supervisor-apps-card-content>
<button class="link" @click=${this._openStore}>
${this.hass.localize(
"ui.panel.config.apps.installed.no_apps"
)}
</button>
</div>
</ha-card>
`
)}
: addons.map(
(addon) => html`
<ha-card
outlined
.addon=${addon}
@click=${this._addonTapped}
>
<div class="card-content">
<supervisor-apps-card-content
.hass=${this.hass}
.title=${addon.name}
.stage=${addon.stage}
.description=${addon.description}
available
.showTopbar=${addon.update_available}
topbarClass="update"
.icon=${addon.update_available
? mdiArrowUpBoldCircle
: mdiPuzzle}
.iconTitle=${addon.state !== "started"
? this.hass.localize(
"ui.panel.config.apps.installed.app_stopped"
)
: addon.update_available
? this.hass.localize(
"ui.panel.config.apps.installed.app_update_available"
)
: this.hass.localize(
"ui.panel.config.apps.installed.app_running"
)}
.iconClass=${addon.update_available
? addon.state === "started"
? "update"
: "update stopped"
: addon.state === "started"
? "running"
: "stopped"}
.iconImage=${addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined}
></supervisor-apps-card-content>
</div>
</ha-card>
`
)}
</div>
</div>
<ha-button size="large" href="/config/apps/available">
@@ -216,32 +217,6 @@ export class HaConfigAppsInstalled extends LitElement {
navigate("/config/apps/available");
}
private _getAppTags(addon: HassioAddonInfo): AppTag[] {
const labels: AppTag[] = [];
if (addon.update_available) {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.state.update_available`
),
variant: "brand",
iconPath: mdiArrowUpBoldCircleOutline,
});
}
if (addon.stage !== "stable") {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.stages.${addon.stage}`
),
variant: addon.stage === "experimental" ? "warning" : "danger",
iconPath:
addon.stage === "experimental" ? mdiFlask : mdiAlertDecagramOutline,
});
}
return labels;
}
static styles: CSSResultGroup = [
supervisorAppsStyle,
css`
@@ -254,10 +229,7 @@ export class HaConfigAppsInstalled extends LitElement {
ha-card {
cursor: pointer;
overflow: hidden;
}
ha-card:hover {
background-color: var(--ha-color-fill-neutral-quiet-resting);
direction: ltr;
}
.search {
@@ -275,13 +247,10 @@ export class HaConfigAppsInstalled extends LitElement {
.content {
padding: var(--ha-space-4);
margin-bottom: var(--ha-space-18);
gap: var(--ha-space-4);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(336px, 100%), 1fr));
}
.card-content {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
padding: var(--ha-space-4);
}
button.link {
@@ -866,10 +866,13 @@ export default class HaAutomationAddFromTarget extends LitElement {
undefined
);
const filteredFloors = this._floorAreas.filter(
({ id, areas }) => id !== undefined && areas.length
);
this._floorAreas.forEach((floor) => {
this._entries[floor.id || `floor${TARGET_SEPARATOR}`] = {
// auto expand if only one floor is present
open: this._floorAreas.length === 1,
open: filteredFloors.length === 1 && filteredFloors[0].id === floor.id,
areas: {},
};
@@ -53,6 +53,7 @@ import "./ha-domain-integrations";
import "./ha-integration-list-item";
import type { AddIntegrationDialogParams } from "./show-add-integration-dialog";
import { showYamlIntegrationDialog } from "./show-add-integration-dialog";
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
export interface IntegrationListItem {
name: string;
@@ -710,21 +711,8 @@ class AddIntegrationDialog extends LitElement {
});
if (configEntries.length > 0) {
this.closeDialog();
const localize = await this.hass.loadBackendTranslation(
"title",
integration.name
);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry",
{
integration_name: domainToName(localize, integration.name),
}
),
});
showSingleConfigEntryWarning(this, { domain: integration.domain });
return;
}
}
@@ -0,0 +1,86 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-button";
import { internationalizationContext } from "../../../data/context";
import { domainToName } from "../../../data/integration";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import type { SingleConfigEntryWarningDialogParams } from "./show-single-config-entry-warning";
@customElement("dialog-single-config-entry-warning")
class DialogSingleConfigEntryWarning extends DialogMixin<SingleConfigEntryWarningDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _backendLocalize?: LocalizeFunc;
connectedCallback() {
super.connectedCallback();
this._loadBackendLocalize();
}
protected render() {
if (!this.params || !this._backendLocalize) {
return nothing;
}
return html`
<ha-dialog
open
.headerTitle=${this._i18n.localize(
"ui.panel.config.integrations.config_flow.single_config_entry_title"
)}
>
${this._i18n.localize(
"ui.panel.config.integrations.config_flow.single_config_entry",
{
integration_name: html`<b
>${domainToName(this._backendLocalize, this.params.domain)}</b
>`,
}
)}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.close")}
</ha-button>
<ha-button
slot="primaryAction"
.href=${`/config/integrations/integration/${this.params.domain}`}
>
${this._i18n.localize(
"ui.panel.config.integrations.config_flow.show_integration"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private async _loadBackendLocalize() {
if (!this.params) {
return;
}
this._backendLocalize = await this._i18n.loadBackendTranslation(
"title",
this.params.domain
);
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-single-config-entry-warning": DialogSingleConfigEntryWarning;
}
}
@@ -621,7 +621,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</ha-button>
`
: nothing}
${this._manifest?.integration_type !== "hardware"
${this._manifest?.integration_type !== "hardware" &&
(!this._manifest?.single_config_entry ||
(normalData.length === 0 && attentionData.length === 0))
? html`<ha-button
.appearance=${canAddDevice ? "filled" : "accent"}
@click=${this._addIntegration}
@@ -1235,30 +1237,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
});
return;
}
if (this._manifest?.single_config_entry) {
const entries = this._domainConfigEntries(
this.domain,
this._extraConfigEntries || this.configEntries
);
if (entries.length > 0) {
const localize = await this.hass.loadBackendTranslation(
"title",
this._manifest.name
);
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry",
{
integration_name: domainToName(localize, this._manifest.name),
}
),
});
return;
}
}
showAddIntegrationDialog(this, {
domain: this.domain,
navigateToResult: true,
@@ -69,6 +69,7 @@ import "./ha-integration-card";
import type { HaIntegrationCard } from "./ha-integration-card";
import "./ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
export interface ConfigEntryExtended extends Omit<ConfigEntry, "entry_id"> {
entry_id?: string;
@@ -914,21 +915,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
if (integration.single_config_entry) {
const configEntries = await getConfigEntries(this.hass, { domain });
if (configEntries.length > 0) {
const localize = await this.hass.loadBackendTranslation(
"title",
integration.name
);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.single_config_entry",
{
integration_name: domainToName(localize, integration.name!),
}
),
});
showSingleConfigEntryWarning(this, { domain });
return;
}
}
@@ -0,0 +1,16 @@
import type { LitElement } from "lit";
import { fireEvent } from "../../../common/dom/fire_event";
export interface SingleConfigEntryWarningDialogParams {
domain: string;
}
export const showSingleConfigEntryWarning = (
element: LitElement,
params: SingleConfigEntryWarningDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-single-config-entry-warning",
dialogParams: params,
dialogImport: () => import("./dialog-single-config-entry-warning"),
});
+1 -7
View File
@@ -2924,13 +2924,6 @@
"experimental": "Experimental",
"deprecated": "Deprecated"
},
"state": {
"started": "Running",
"stopped": "Stopped",
"error": "Error",
"startup": "Starting",
"unknown": "Unknown"
},
"label": {
"rating": "Rating",
"host": "Host",
@@ -6917,6 +6910,7 @@
"supported_hardware": "supported hardware",
"proceed": "Proceed",
"single_config_entry_title": "This integration allows only one configuration",
"show_integration": "Show integration",
"single_config_entry": "{integration_name} supports only one configuration. Adding additional ones is not needed."
}
},