1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

Merge branch 'rc'

This commit is contained in:
Bram Kragten
2025-12-19 17:05:32 +01:00
13 changed files with 291 additions and 66 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20251203.2" version = "20251203.3"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*"] license-files = ["LICENSE*"]
description = "The Home Assistant frontend" description = "The Home Assistant frontend"

View File

@@ -659,7 +659,7 @@ export class HaAssistChat extends LitElement {
--markdown-table-border-color: var(--divider-color); --markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color); --markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color); --markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1rem; --markdown-list-indent: 1.15em;
&:not(:has(ha-markdown-element)) { &:not(:has(ha-markdown-element)) {
min-height: 1lh; min-height: 1lh;
min-width: 1lh; min-width: 1lh;

View File

@@ -344,7 +344,10 @@ export class HaGenericPicker extends LitElement {
wa-popover::part(body) { wa-popover::part(body) {
width: max(var(--body-width), 250px); width: max(var(--body-width), 250px);
max-width: max(var(--body-width), 250px); max-width: var(
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
);
max-height: 500px; max-height: 500px;
height: 70vh; height: 70vh;
overflow: hidden; overflow: hidden;

View File

@@ -74,9 +74,6 @@ export class HaMarkdown extends LitElement {
background-color: var(--markdown-image-background-color); background-color: var(--markdown-image-background-color);
border-radius: var(--markdown-image-border-radius); border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
} }
p:first-child > img:first-child { p:first-child > img:first-child {
vertical-align: top; vertical-align: top;
@@ -84,8 +81,7 @@ export class HaMarkdown extends LitElement {
p:first-child > img:last-child { p:first-child > img:last-child {
vertical-align: top; vertical-align: top;
} }
:host > ul, ha-markdown-element > :is(ol, ul) {
:host > ol {
padding-inline-start: var(--markdown-list-indent, revert); padding-inline-start: var(--markdown-list-indent, revert);
} }
li { li {
@@ -136,6 +132,18 @@ export class HaMarkdown extends LitElement {
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table[role="presentation"] {
--markdown-table-border-collapse: separate;
--markdown-table-border-width: attr(border, 0);
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th {
vertical-align: attr(align, center);
}
td {
vertical-align: attr(align, left);
}
}
table { table {
border-collapse: var(--markdown-table-border-collapse, collapse); border-collapse: var(--markdown-table-border-collapse, collapse);
} }
@@ -143,14 +151,15 @@ export class HaMarkdown extends LitElement {
overflow: auto; overflow: auto;
} }
th { th {
text-align: start; text-align: var(--markdown-table-text-align, start);
} }
td, td,
th { th {
border-width: var(--markdown-table-border-width, 1px); border-width: var(--markdown-table-border-width, 1px);
border-style: var(--markdown-table-border-style, solid); border-style: var(--markdown-table-border-style, solid);
border-color: var(--markdown-table-border-color, var(--divider-color)); border-color: var(--markdown-table-border-color, var(--divider-color));
padding: 0.25em 0.5em; padding-inline: var(--markdown-table-padding-inline, 0.5em);
padding-block: var(--markdown-table-padding-block, 0.25em);
} }
blockquote { blockquote {
border-left: 4px solid var(--divider-color); border-left: 4px solid var(--divider-color);

View File

@@ -952,10 +952,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false; let hasFloor = false;
let rtl = false; let rtl = false;
let showEntityId = false; let showEntityId = false;
if (type === "area" || type === "floor") { if (type === "area" || type === "floor") {
item.id = item[type]?.[`${type}_id`];
rtl = computeRTL(this.hass); rtl = computeRTL(this.hass);
hasFloor = hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;

View File

@@ -13,6 +13,7 @@ export class HaToast extends Snackbar {
} }
.mdc-snackbar { .mdc-snackbar {
z-index: 10;
margin: 8px; margin: 8px;
right: calc(8px + var(--safe-area-inset-right)); right: calc(8px + var(--safe-area-inset-right));
bottom: calc(8px + var(--safe-area-inset-bottom)); bottom: calc(8px + var(--safe-area-inset-bottom));

View File

@@ -321,12 +321,16 @@ export class HaAutomationAddSearch extends LitElement {
></ha-tree-indicator> ></ha-tree-indicator>
` `
: nothing} : nothing}
${item.icon ${(item as AutomationItemComboBoxItem).renderedIcon
? html`<div slot="start">
${(item as AutomationItemComboBoxItem).renderedIcon}
</div>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path : item.icon_path || type === "area"
? html`<ha-svg-icon ? html`<ha-svg-icon
slot="start" slot="start"
.path=${item.icon_path} .path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>` ></ha-svg-icon>`
: type === "entity" && (item as EntityComboBoxItem).stateObj : type === "entity" && (item as EntityComboBoxItem).stateObj
? html` ? html`
@@ -350,11 +354,6 @@ export class HaAutomationAddSearch extends LitElement {
slot="start" slot="start"
.floor=${(item as FloorComboBoxItem).floor!} .floor=${(item as FloorComboBoxItem).floor!}
></ha-floor-icon>` ></ha-floor-icon>`
: type === "area"
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`
: nothing} : nothing}
<span slot="headline">${item.primary}</span> <span slot="headline">${item.primary}</span>
${item.secondary ${item.secondary
@@ -784,7 +783,7 @@ export class HaAutomationAddSearch extends LitElement {
id: key, id: key,
primary: name, primary: name,
secondary: description, secondary: description,
iconPath, icon_path: iconPath,
renderedIcon: icon, renderedIcon: icon,
type, type,
search_labels: [key, name, description], search_labels: [key, name, description],

View File

@@ -631,6 +631,7 @@ class HaPanelHistory extends LitElement {
:host([virtualize]) { :host([virtualize]) {
height: 100%; height: 100%;
--ha-generic-picker-max-width: 400px;
} }
.progress-wrapper { .progress-wrapper {

View File

@@ -303,6 +303,9 @@ export class HaPanelLogbook extends LitElement {
return [ return [
haStyle, haStyle,
css` css`
:host {
--ha-generic-picker-max-width: 400px;
}
ha-logbook { ha-logbook {
height: calc( height: calc(
100vh - 100vh -

View File

@@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
}); });
} }
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
const item =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: { value: datapoint };
return Number(item.value?.[0]);
}
export function fillLineGaps(datasets: LineSeriesOption[]) { export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from( const buckets = Array.from(
new Set( new Set(
datasets datasets
.map((dataset) => .map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0])) dataset.data!.map((datapoint) => getDatapointX(datapoint))
) )
.flat() .flat()
) )
).sort((a, b) => a - b); ).sort((a, b) => a - b);
buckets.forEach((bucket, index) => {
for (let i = datasets.length - 1; i >= 0; i--) { datasets.forEach((dataset) => {
const dataPoint = datasets[i].data![index]; const dataMap = new Map<number, LineDataItemOption>();
dataset.data!.forEach((datapoint) => {
const item: LineDataItemOption = const item: LineDataItemOption =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint datapoint && typeof datapoint === "object" && "value" in datapoint
? dataPoint ? datapoint
: ({ value: dataPoint } as LineDataItemOption); : ({ value: datapoint } as LineDataItemOption);
const x = item.value?.[0]; const x = getDatapointX(datapoint);
if (x === undefined) { if (!Number.isNaN(x)) {
continue; dataMap.set(x, item);
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, [bucket, 0]);
}
} }
}); });
dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]);
});
return datasets; return datasets;
} }

View File

@@ -41,7 +41,9 @@ const computeHeadingCard = (
action: "navigate", action: "navigate",
navigation_path, navigation_path,
} }
: undefined, : {
action: "none",
},
}) satisfies HeadingCardConfig; }) satisfies HeadingCardConfig;
@customElement("home-area-view-strategy") @customElement("home-area-view-strategy")
@@ -182,7 +184,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
computeHeadingCard( computeHeadingCard(
hass.localize("ui.panel.lovelace.strategy.home.scenes"), hass.localize("ui.panel.lovelace.strategy.home.scenes"),
"mdi:palette", "mdi:palette",
"/config/scene/dashboard" hass.user?.is_admin ? "/config/scene/dashboard" : undefined
), ),
...scenes.map(computeTileCard), ...scenes.map(computeTileCard),
], ],
@@ -285,12 +287,13 @@ export class HomeAreaViewStrategy extends ReactiveElement {
{ {
type: "heading", type: "heading",
heading: heading, heading: heading,
tap_action: device tap_action:
hass.user?.is_admin && device
? { ? {
action: "navigate", action: "navigate",
navigation_path: `/config/devices/device/${device.id}`, navigation_path: `/config/devices/device/${device.id}`,
} }
: undefined, : { action: "none" },
badges: [ badges: [
...batteryEntities.slice(0, 1).map((e) => ({ ...batteryEntities.slice(0, 1).map((e) => ({
entity: e, entity: e,
@@ -334,7 +337,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
computeHeadingCard( computeHeadingCard(
hass.localize("ui.panel.lovelace.strategy.home.automations"), hass.localize("ui.panel.lovelace.strategy.home.automations"),
"mdi:robot", "mdi:robot",
"/config/automation/dashboard" hass.user?.is_admin ? "/config/automation/dashboard" : undefined
), ),
...automations.map(computeTileCard), ...automations.map(computeTileCard),
], ],

View File

@@ -19,6 +19,7 @@ const renderMarkdown = async (
if (!whiteListNormal) { if (!whiteListNormal) {
whiteListNormal = { whiteListNormal = {
...getDefaultWhiteList(), ...getDefaultWhiteList(),
table: [...(getDefaultWhiteList().table ?? []), "role"],
input: ["type", "disabled", "checked"], input: ["type", "disabled", "checked"],
"ha-icon": ["icon"], "ha-icon": ["icon"],
"ha-svg-icon": ["path"], "ha-svg-icon": ["path"],

View File

@@ -0,0 +1,199 @@
import { assert, describe, it } from "vitest";
import type { LineSeriesOption } from "echarts/charts";
import { fillLineGaps } from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
// Helper to get x value from either [x,y] or {value: [x,y]} format
function getX(item: any): number {
return item?.value?.[0] ?? item?.[0];
}
// Helper to get y value from either [x,y] or {value: [x,y]} format
function getY(item: any): number {
return item?.value?.[1] ?? item?.[1];
}
describe("fillLineGaps", () => {
it("fills gaps in datasets with missing timestamps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[3000, 30],
],
},
{
type: "line",
data: [
[1000, 100],
[2000, 200],
[3000, 300],
],
},
];
const result = fillLineGaps(datasets);
// First dataset should have gap at 2000 filled with 0
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 0);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
// Second dataset should be unchanged
assert.equal(result[1].data!.length, 3);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.equal(getX(result[1].data![1]), 2000);
assert.equal(getY(result[1].data![1]), 200);
assert.equal(getX(result[1].data![2]), 3000);
assert.equal(getY(result[1].data![2]), 300);
});
it("handles unsorted data from multiple sources", () => {
// This is the bug we're fixing: when multiple power sources are combined,
// the data may not be in chronological order
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[3000, 30],
[1000, 10],
[2000, 20],
],
},
];
const result = fillLineGaps(datasets);
// Data should be sorted by timestamp
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
});
it("handles multiple datasets with unsorted data", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[3000, 30],
[1000, 10],
],
},
{
type: "line",
data: [
[2000, 200],
[1000, 100],
[3000, 300],
],
},
];
const result = fillLineGaps(datasets);
// First dataset should be sorted and have gap at 2000 filled
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 0);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
// Second dataset should be sorted
assert.equal(result[1].data!.length, 3);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.equal(getX(result[1].data![1]), 2000);
assert.equal(getY(result[1].data![1]), 200);
assert.equal(getX(result[1].data![2]), 3000);
assert.equal(getY(result[1].data![2]), 300);
});
it("handles data with object format (LineDataItemOption)", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [{ value: [3000, 30] }, { value: [1000, 10] }],
},
];
const result = fillLineGaps(datasets);
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 3000);
assert.equal(getY(result[0].data![1]), 30);
});
it("returns empty array for empty datasets", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [],
},
];
const result = fillLineGaps(datasets);
assert.deepEqual(result[0].data, []);
});
it("handles already sorted data with no gaps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[2000, 20],
[3000, 30],
],
},
];
const result = fillLineGaps(datasets);
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
});
it("preserves original data item properties", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
{ value: [2000, 20], itemStyle: { color: "red" } },
{ value: [1000, 10], itemStyle: { color: "blue" } },
],
},
];
const result = fillLineGaps(datasets);
// First item should be the one with timestamp 1000
const firstItem = result[0].data![0] as any;
assert.equal(getX(firstItem), 1000);
assert.equal(firstItem.itemStyle.color, "blue");
// Second item should be the one with timestamp 2000
const secondItem = result[0].data![1] as any;
assert.equal(getX(secondItem), 2000);
assert.equal(secondItem.itemStyle.color, "red");
});
});