Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Lamon
db5f823b6b Remove twine and introduce trusted publishing (#27110)
* Remove twine and introduce trusted publishing

* Update release.yaml
2026-01-15 22:50:52 -05:00
uptimeZERO_
1d241aa49a Truncate long menu item labels in the sidebar (#29005) 2026-01-15 15:41:48 +00:00
Pegasus
fece231faf fix: restrict to exact match for data table (#28853) 2026-01-15 15:50:15 +01:00
7 changed files with 363 additions and 361 deletions

View File

@@ -19,8 +19,11 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: write # Required to upload release assets
id-token: write # For "Trusted Publisher" to PyPi
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -46,14 +49,18 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Build and release package
run: |
python3 -m pip install twine build
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
python3 -m pip install build
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:

View File

@@ -1,5 +1,4 @@
#!/bin/sh
# Pushes a new version to PyPi.
# Stop on errors
set -e
@@ -12,5 +11,4 @@ yarn install
script/build_frontend
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
python3 -m twine upload dist/*.whl --skip-existing
python3 -m build -q

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse, { type FuseOptionKey } from "fuse.js";
import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,46 +11,159 @@ import type {
SortingDirection,
} from "./ha-data-table";
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
interface FilterKeyConfig {
key: string;
filterKey?: string;
}
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Array.from(searchKeys);
const getFilterKeys = memoizeOne(
(columns: SortableColumnContainer): FilterKeyConfig[] =>
Object.entries(columns)
.filter(([, column]) => column.filterable)
.map(([key, column]) => ({
key: column.valueColumn || key,
filterKey: column.filterKey,
}))
);
const getSearchableValue = (
row: DataTableRowData,
{ key, filterKey }: FilterKeyConfig
): string => {
let value = row[key];
if (value == null) {
return "";
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
if (filterKey && typeof value === "object" && !Array.isArray(value)) {
value = value[filterKey];
if (value == null) {
return "";
}
}
if (Array.isArray(value)) {
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return stripDiacritics(stringValues.join(" ").toLowerCase());
}
return stripDiacritics(String(value).toLowerCase());
};
/** Filters data using exact substring matching (all terms must match). */
const filterDataExact = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
if (terms.length === 1) {
const term = terms[0];
return data.filter((row) =>
filterKeys.some((config) =>
getSearchableValue(row, config).includes(term)
)
);
}
return data.filter((row) => {
const searchString = filterKeys
.map((config) => getSearchableValue(row, config))
.join(" ");
return terms.every((term) => searchString.includes(term));
});
};
const FUZZY_OPTIONS: IFuseOptions<DataTableRowData> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.2, // Stricter than default 0.3
minMatchCharLength: 2,
ignoreLocation: true,
shouldSort: false,
};
interface FuseKeyConfig {
name: string | string[];
getFn: (row: DataTableRowData) => string;
}
/** Filters data using fuzzy matching with Fuse.js (all terms must match). */
const filterDataFuzzy = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
// Build Fuse.js search keys from filter keys
const fuseKeys: FuseKeyConfig[] = filterKeys.map((config) => ({
name: config.filterKey ? [config.key, config.filterKey] : config.key,
getFn: (row: DataTableRowData) => getSearchableValue(row, config),
}));
// Find minimum term length to adjust minMatchCharLength
const minTermLength = Math.min(...terms.map((t) => t.length));
const minMatchCharLength = Math.min(minTermLength, 2);
const fuse = new Fuse<DataTableRowData>(data, {
...FUZZY_OPTIONS,
keys: fuseKeys as FuseOptionKey<DataTableRowData>[],
minMatchCharLength,
});
// For single term, simple search
if (terms.length === 1) {
return fuse.search(terms[0]).map((r) => r.item);
}
// For multiple terms, all must match (AND logic)
const expression = {
$and: terms.map((term) => ({
$or: fuseKeys.map((key) => ({
$path: Array.isArray(key.name) ? key.name : [key.name],
$val: term,
})),
})),
};
return fuse.search(expression).map((r) => r.item);
};
/**
* Filters data with exact match priority and fuzzy fallback.
* - First tries exact substring matching
* - If exact matches found, returns only those
* - If no exact matches, falls back to fuzzy search with strict scoring
*/
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = stripDiacritics(filter.toLowerCase());
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
if (filter === "") {
if (!normalizedFilter) {
return data;
}
const keys = getSearchKeys(columns);
const filterKeys = getFilterKeys(columns);
const index = fuseIndex(data, keys);
if (!filterKeys.length) {
return data;
}
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
const terms = normalizedFilter.split(/\s+/);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);
if (exactMatches.length > 0) {
return exactMatches;
}
// No exact matches, fall back to fuzzy search
return filterDataFuzzy(data, filterKeys, terms);
};
const sortData = (

View File

@@ -589,10 +589,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
// On keypresses on the listbox, we're going to ignore mouse enter events
// for 100ms so that we ignore it when pressing down arrow scrolls the
// sidebar causing the mouse to hover a new icon
if (
this.alwaysExpand ||
new Date().getTime() < this._recentKeydownActiveUntil
) {
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
if (this._mouseLeaveTimeout) {
@@ -612,7 +609,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _listboxFocusIn(ev) {
if (this.alwaysExpand || ev.target.localName !== "ha-md-list-item") {
if (ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
@@ -652,6 +649,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const itemText = item.querySelector(".item-text") as HTMLElement | null;
if (this.hasAttribute("expanded") && itemText) {
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
if (!isTruncated) {
this._hideTooltip();
return;
}
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
@@ -662,9 +667,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
@@ -846,6 +849,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
:host([expanded]) ha-md-list-item .item-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
@@ -913,7 +919,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
white-space: nowrap;
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);

View File

@@ -1,4 +1,5 @@
import { mdiClose, mdiPlus } from "@mdi/js";
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -18,8 +19,13 @@ import "../../../../components/ha-textarea";
import "../../../../components/ha-textfield";
import "../../category/ha-category-picker";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import type { GenDataTaskResult } from "../../../../data/ai_task";
import { fetchCategoryRegistry } from "../../../../data/category_registry";
import { subscribeEntityRegistry } from "../../../../data/entity/entity_registry";
import { subscribeLabelRegistry } from "../../../../data/label/label_registry";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -27,11 +33,6 @@ import type {
EntityRegistryUpdate,
SaveDialogParams,
} from "./show-dialog-automation-save";
import {
generateMetadataSuggestionTask,
processMetadataSuggestion,
type MetadataSuggestionResult,
} from "../../common/suggest-metadata-ai";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -334,49 +335,180 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this.closeDialog();
}
private _generateTask = async (): Promise<SuggestWithAIGenerateTask> =>
generateMetadataSuggestionTask(this.hass, {
domain: this._params.domain,
config: this._params.config,
includeDescription: true,
});
private _getSuggestData() {
return Promise.all([
subscribeOne(this.hass.connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
),
subscribeOne(this.hass.connection, subscribeEntityRegistry).then((ents) =>
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
),
fetchCategoryRegistry(this.hass.connection, "automation").then((cats) =>
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
),
]);
}
private _generateTask = async (): Promise<SuggestWithAIGenerateTask> => {
const [labels, entities, categories] = await this._getSuggestData();
const inspirations: string[] = [];
const domain = this._params.domain;
for (const entity of Object.values(this.hass.states)) {
const entityEntry = entities[entity.entity_id];
if (
computeStateDomain(entity) !== domain ||
entity.attributes.restored ||
!entity.attributes.friendly_name ||
!entityEntry
) {
continue;
}
let inspiration = `- ${entity.attributes.friendly_name}`;
const category = categories[entityEntry.categories.automation];
if (category) {
inspiration += ` (category: ${category})`;
}
if (entityEntry.labels.length) {
inspiration += ` (labels: ${entityEntry.labels
.map((label) => labels[label])
.join(", ")})`;
}
inspirations.push(inspiration);
}
const term = this._params.domain === "script" ? "script" : "automation";
return {
type: "data",
task: {
task_name: `frontend__${term}__save`,
instructions: `Suggest in language "${this.hass.language}" a name, description, category and labels for the following Home Assistant ${term}.
The name should be relevant to the ${term}'s purpose.
${
inspirations.length
? `The name should be in same style and sentence capitalization as existing ${term}s.
Suggest a category and labels if relevant to the ${term}'s purpose.
Only suggest category and labels that are already used by existing ${term}s.`
: `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.`
}
If the ${term} contains 5+ steps, include a short description.
For inspiration, here are existing ${term}s:
${inspirations.join("\n")}
The ${term} configuration is as follows:
${dump(this._params.config)}
`,
structure: {
name: {
description: "The name of the automation",
required: true,
selector: {
text: {},
},
},
description: {
description: "A short description of the automation",
required: false,
selector: {
text: {},
},
},
labels: {
description: "Labels for the automation",
required: false,
selector: {
text: {
multiple: true,
},
},
},
category: {
description: "The category of the automation",
required: false,
selector: {
select: {
options: Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
})),
},
},
},
},
},
};
};
private async _handleSuggestion(
event: CustomEvent<GenDataTaskResult<MetadataSuggestionResult>>
event: CustomEvent<
GenDataTaskResult<{
name: string;
description?: string;
category?: string;
labels?: string[];
}>
>
) {
const result = event.detail;
const processed = await processMetadataSuggestion(
this.hass,
this._params.domain,
result
);
const [labels, _entities, categories] = await this._getSuggestData();
this._newName = processed.name;
if (processed.description) {
this._newDescription = processed.description;
this._newName = result.data.name;
if (result.data.description) {
this._newDescription = result.data.description;
if (!this._visibleOptionals.includes("description")) {
this._visibleOptionals = [...this._visibleOptionals, "description"];
}
}
if (processed.categoryId) {
this._entryUpdates = {
...this._entryUpdates,
category: processed.categoryId,
};
if (!this._visibleOptionals.includes("category")) {
this._visibleOptionals = [...this._visibleOptionals, "category"];
if (result.data.category) {
// We get back category name, convert it to ID
const categoryId = Object.entries(categories).find(
([, name]) => name === result.data.category
)?.[0];
if (categoryId) {
this._entryUpdates = {
...this._entryUpdates,
category: categoryId,
};
if (!this._visibleOptionals.includes("category")) {
this._visibleOptionals = [...this._visibleOptionals, "category"];
}
}
}
if (processed.labelIds?.length) {
this._entryUpdates = {
...this._entryUpdates,
labels: processed.labelIds,
};
if (!this._visibleOptionals.includes("labels")) {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
if (result.data.labels?.length) {
// We get back label names, convert them to IDs
const newLabels: Record<string, undefined | string> = Object.fromEntries(
result.data.labels.map((name) => [name, undefined])
);
let toFind = result.data.labels.length;
for (const [labelId, labelName] of Object.entries(labels)) {
if (labelName in newLabels && newLabels[labelName] === undefined) {
newLabels[labelName] = labelId;
toFind--;
if (toFind === 0) {
break;
}
}
}
const foundLabels = Object.values(newLabels).filter(
(labelId) => labelId !== undefined
);
if (foundLabels.length) {
this._entryUpdates = {
...this._entryUpdates,
labels: foundLabels,
};
if (!this._visibleOptionals.includes("labels")) {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
}
}

View File

@@ -1,263 +0,0 @@
import { dump } from "js-yaml";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { subscribeOne } from "../../../common/util/subscribe-one";
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
import { fetchCategoryRegistry } from "../../../data/category_registry";
import {
subscribeEntityRegistry,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { subscribeLabelRegistry } from "../../../data/label/label_registry";
import type { HomeAssistant } from "../../../types";
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
export interface MetadataSuggestionResult {
name: string;
description?: string;
category?: string;
labels?: string[];
}
export interface ProcessedMetadataSuggestionResult {
name: string;
description?: string;
categoryId?: string;
labelIds?: string[];
}
export interface MetadataSuggestionConfig {
/** The domain to suggest metadata for (automation, script) */
domain: "automation" | "script";
/** The configuration to suggest metadata for */
config: any;
/** Whether to include description field in the suggestion */
includeDescription?: boolean;
/** Whether to include icon field in the suggestion (scripts only) */
includeIcon?: boolean;
}
type Categories = Record<string, string>;
type Entities = Record<string, EntityRegistryEntry>;
type Labels = Record<string, string>;
const fetchCategories = (
connection: HomeAssistant["connection"],
domain: MetadataSuggestionConfig["domain"]
): Promise<Categories> =>
fetchCategoryRegistry(connection, domain).then((cats) =>
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
);
const fetchEntities = (
connection: HomeAssistant["connection"]
): Promise<Entities> =>
subscribeOne(connection, subscribeEntityRegistry).then((ents) =>
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
);
const fetchLabels = (
connection: HomeAssistant["connection"]
): Promise<Labels> =>
subscribeOne(connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
);
function buildMetadataInspirations(
states: HomeAssistant["states"],
entities: Record<string, EntityRegistryEntry>,
categories: Categories,
labels: Labels,
domain: MetadataSuggestionConfig["domain"]
): string[] {
const inspirations: string[] = [];
for (const entity of Object.values(states)) {
const entityEntry = entities[entity.entity_id];
if (
!entityEntry ||
computeStateDomain(entity) !== domain ||
entity.attributes.restored ||
!entity.attributes.friendly_name
) {
continue;
}
let inspiration = `- ${entity.attributes.friendly_name}`;
// Get the category for this domain
const category = categories[entityEntry.categories[domain]];
if (category) {
inspiration += ` (category: ${category})`;
}
if (entityEntry.labels.length) {
inspiration += ` (labels: ${entityEntry.labels
.map((label) => labels[label])
.join(", ")})`;
}
inspirations.push(inspiration);
}
return inspirations;
}
export async function generateMetadataSuggestionTask(
connection: HomeAssistant["connection"],
states: HomeAssistant["states"],
language: HomeAssistant["language"],
suggestionConfig: MetadataSuggestionConfig
): Promise<SuggestWithAIGenerateTask> {
const { domain, config, includeDescription } = suggestionConfig;
let categories: Categories = {};
let entities: Entities = {};
let labels: Labels = {};
try {
[categories, entities, labels] = await Promise.all([
fetchCategories(connection, domain),
fetchEntities(connection),
fetchLabels(connection),
]);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error getting suggest metadata:", error);
}
const inspirations = buildMetadataInspirations(
states,
entities,
categories,
labels,
domain
);
const structure: AITaskStructure = {
name: {
description: `The name of the ${domain}`,
required: true,
selector: {
text: {},
},
},
...(includeDescription && {
description: {
description: `A short description of the ${domain}`,
required: false,
selector: {
text: {},
},
},
}),
labels: {
description: `Labels for the ${domain}`,
required: false,
selector: {
text: {
multiple: true,
},
},
},
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
})),
},
},
},
};
return {
type: "data",
task: {
task_name: `frontend__${domain}__save`,
instructions: `Suggest in language "${language}" a name${includeDescription ? ", description" : ""}, category and labels for the following Home Assistant ${domain}.
The name should be relevant to the ${domain}'s purpose.
${
inspirations.length
? `The name should be in same style and sentence capitalization as existing ${domain}s.
Suggest a category and labels if relevant to the ${domain}'s purpose.
Only suggest category and labels that are already used by existing ${domain}s.`
: `The name should be short, descriptive, sentence case, and written in the language ${language}.`
}${
includeDescription
? `
If the ${domain} contains 5+ steps, include a short description.`
: ""
}
For inspiration, here are existing ${domain}s:
${inspirations.join("\n")}
The ${domain} configuration is as follows:
${dump(config)}
`,
structure,
},
};
}
export async function processMetadataSuggestion(
connection: HomeAssistant["connection"],
domain: MetadataSuggestionConfig["domain"],
result: GenDataTaskResult<MetadataSuggestionResult>
): Promise<ProcessedMetadataSuggestionResult> {
let categories: Categories = {};
let labels: Labels = {};
try {
[categories, labels] = await Promise.all([
fetchCategories(connection, domain),
fetchLabels(connection),
]);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error getting suggest metadata:", error);
}
const processed: ProcessedMetadataSuggestionResult = {
name: result.data.name,
description: result.data.description ?? undefined,
};
// Convert category name to ID
if (result.data.category) {
const categoryId = Object.entries(categories).find(
([, name]) => name === result.data.category
)?.[0];
if (categoryId) {
processed.categoryId = categoryId;
}
}
// Convert label names to IDs
if (result.data.labels?.length) {
const newLabels: Record<string, undefined | string> = Object.fromEntries(
result.data.labels.map((name) => [name, undefined])
);
let toFind = result.data.labels.length;
for (const [labelId, labelName] of Object.entries(labels)) {
if (labelName in newLabels && newLabels[labelName] === undefined) {
newLabels[labelName] = labelId;
toFind--;
if (toFind === 0) {
break;
}
}
}
const foundLabels = Object.values(newLabels).filter(
(labelId) => labelId !== undefined
);
if (foundLabels.length) {
processed.labelIds = foundLabels;
}
}
return processed;
}

View File

@@ -175,24 +175,31 @@ export class HaConfigLovelaceDashboards extends LitElement {
template: narrow
? undefined
: (dashboard) => html`
${dashboard.title}
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
<span
style="display:flex; align-items:center; gap: var(--ha-space-2); min-width:0; width:100%;"
>
<span
style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;"
>${dashboard.title}</span
>
${dashboard.default
? html`
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="flex-shrink:0;"
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
placement="right"
>
${this.hass.localize(
`ui.panel.config.lovelace.dashboards.default_dashboard`
)}
</ha-tooltip>
`
: nothing}
</span>
`,
},
};