Compare commits

...
Author SHA1 Message Date
Franck Nijhof dca4a8e0a9 Load Marketplace icons through the Marketplace brands view (#54740) 2026-10-08 15:27:54 -04:00
480182c2b3 Make the Marketplace warning a friendlier modal over the Marketplace (#54586)
* Make the Marketplace warning a friendlier modal over the Marketplace

Replace the full-page first-run warning with a calmer dialog shown over
the Marketplace, which stays visible but can't be used until the notice
is accepted. Continue is enabled once the checkbox is ticked, without
the 30 second countdown. Go back, or Escape, leaves the Marketplace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LZuGZHa3FZ9tNHtuuSy8c3

* Update the Marketplace warning to the new design

Lead with what installing can do, in a highlighted list, followed by
numbered steps to stay safe, and agree to the risks explicitly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LZuGZHa3FZ9tNHtuuSy8c3

* Update the Marketplace warning tests to the modal

Test the warning's accept, failure and go back logic instead of the
countdown, test that the Marketplace is inert behind the warning and
starts over once it is accepted, and drop the countdown strings.

Focus the checkbox when the warning opens, and drop the close handler:
neither Escape nor the scrim close the warning, Go back leaves it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LZuGZHa3FZ9tNHtuuSy8c3

* Keep links from acting behind the Marketplace warning

Until the warning is accepted, the Marketplace behind it shows the
catalog whatever the route is, so a link can't add a repository or open
a dialog over the warning. It starts over on the route once accepted.

Use the animation duration token for the agree row, which respects
reduced motion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LZuGZHa3FZ9tNHtuuSy8c3

* Add role attribute to marketplace warning list

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Enhance ha-dialog-header and ha-marketplace-warning components with improved structure and styling

* Refactor marketplace panel tests to improve clarity and structure

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-08 20:30:54 +02:00
Petar Petrov 2d11eba37f Show only serial integrations when setting up an ESPHome serial port (#54668)
* Show only serial integrations when setting up an ESPHome serial port

The add integration dialog gets an integrationFilter param with a list of
domains and trims the integration descriptions to them. The ESPHome serial
Set up button fetches usb/list_serial_integrations and passes it. If that
fails, it opens the unfiltered dialog as before.

* Check for a newer add integration load after loading translations
2026-10-08 20:23:20 +02:00
Maarten Lakerveld 5821cc6e53 Drop the Mapbox RTL text plugin now that MapLibre shapes RTL text itself (#54706)
Since MapLibre 6.9.0 Arabic and Hebrew labels are shaped and reordered by
MapLibre, and setRTLTextPlugin is deprecated. We were still loading the
0.4.0 plugin, which only replaced that built-in implementation with the
same result. Version 1.0.0 of the plugin drops the UMD bundle we copied
into static assets and no longer works as a plugin at all, so bumping it
breaks the build. Remove the dependency, the static copy and the plugin
registration instead.
2026-10-08 20:21:45 +02:00
renovate[bot] ade5d15fee Update dependency @formatjs/intl-datetimeformat to v7.8.1 (#54735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 18:15:34 +00:00
Paul Bottein b8ea4d12b9 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-08 20:14:37 +02:00
24 changed files with 466 additions and 445 deletions
-4
View File
@@ -77,10 +77,6 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
+1 -2
View File
@@ -53,7 +53,7 @@
"@codemirror/view": "6.43.13",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.0",
"@formatjs/intl-datetimeformat": "7.8.1",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
@@ -77,7 +77,6 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "0.27.0",
"@material/mwc-list": "0.27.0",
+5 -13
View File
@@ -222,8 +222,8 @@ importers:
specifier: 2.0.17
version: 2.0.17
'@formatjs/intl-datetimeformat':
specifier: 7.8.0
version: 7.8.0
specifier: 7.8.1
version: 7.8.1
'@formatjs/intl-displaynames':
specifier: 7.3.15
version: 7.3.15
@@ -293,9 +293,6 @@ importers:
'@lit/task':
specifier: 1.0.3
version: 1.0.3
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
'@maplibre/maplibre-gl-leaflet':
specifier: 0.1.4
version: 0.1.4(@types/[email protected])([email protected])([email protected])
@@ -1967,8 +1964,8 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
'@formatjs/[email protected].0':
resolution: {integrity: sha512-SvEbCcvcGN4l4j/k59hXyCoamKWoSKuvSsTSqa85DX6xkHWvsI6hSDJXKSzADF8vdJvIT7v+2n0TXLG/pRbLJg==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-tGVZ3KdbHQwmA8wBn7gEJeOE9zo0InoyrbJWsD4VYdHGJfXykflOpdNC69+QaZShxfEQ6dwUfXjDfNzxe4AO4A==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-U26iRwxxnD4aZY+aDiSEV5Ym/7Db5S9OxKQ4c3T+WFFIj+DPKEQLh+tC++dMH1SuEa8CSWvPJkpaADa+WHn3qA==}
@@ -2258,9 +2255,6 @@ packages:
resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
engines: {node: '>= 22'}
'@mapbox/[email protected]':
resolution: {integrity: sha512-WNSgEFxXd2a15Eh2GwZNWBiV7Z8leq8JEGcoHpfrgaBDmFQMW4a1MO5Ol9BNv6APqTbHZ8uq7L8H5q2KCVt1kg==}
'@mapbox/[email protected]':
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
@@ -9413,7 +9407,7 @@ snapshots:
'@formatjs/[email protected]': {}
'@formatjs/[email protected].0':
'@formatjs/[email protected].1':
dependencies:
'@formatjs/bigdecimal': 0.2.7
'@formatjs/intl-localematcher': 0.9.0
@@ -9791,8 +9785,6 @@ snapshots:
'@mapbox/[email protected]': {}
'@mapbox/[email protected]': {}
'@mapbox/[email protected]': {}
'@mapbox/[email protected]': {}
+1 -24
View File
@@ -1,10 +1,6 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type {
setRTLTextPlugin,
setWorkerUrl,
StyleSpecification,
} from "maplibre-gl";
import type { setWorkerUrl, StyleSpecification } from "maplibre-gl";
import { deepEqual } from "../util/deep-equal";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import type { ResolvedMapStyle } from "./map-styles";
@@ -24,10 +20,6 @@ const SHIPPED_STYLES = {
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
@@ -162,20 +154,6 @@ export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
@@ -390,7 +368,6 @@ export const createBaseLayer = async (
import("maplibre-gl"),
]);
ensureWorkerUrl(maplibre.setWorkerUrl);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
@@ -16,7 +16,6 @@ import {
} from "../marker-accessibility";
import {
CONTEXT_RESTORE_GRACE,
ensureRTLTextPlugin,
ensureWorkerUrl,
loadStyle,
MAP_MAX_ZOOM,
@@ -320,7 +319,6 @@ export class MapLibreMapEngine implements MapEngine {
const maplibre = await import("maplibre-gl");
this._maplibre = maplibre;
ensureWorkerUrl(maplibre.setWorkerUrl);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
// MapLibre's stylesheet for controls and popups; one link per root
const root = container.parentNode;
+22 -1
View File
@@ -1,6 +1,27 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
/**
* @element ha-dialog-header
* @extends {LitElement}
*
* @summary
* Dialog header bar with a navigation icon, title, subtitle, and action items.
*
* @slot navigationIcon - Leading action, such as a close or back button.
* @slot title - Title text.
* @slot subtitle - Subtitle text.
* @slot actionItems - Trailing actions, such as buttons or menus.
* @slot - Content below the header bar.
*
* @cssprop --ha-dialog-header-white-space - White space of the title and subtitle, set to `normal` to let them wrap. Defaults to `nowrap`.
* @cssprop --ha-dialog-header-title-height - Height of the title, set to `auto` when it wraps.
* @cssprop --ha-dialog-header-title-color - Color of the title.
* @cssprop --ha-dialog-header-subtitle-color - Color of the subtitle.
*
* @attr {("above"|"below")} subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} show-border - Shows a border below the header.
*/
@customElement("ha-dialog-header")
export class HaDialogHeader extends LitElement {
@property({ type: String, attribute: "subtitle-position" })
@@ -67,7 +88,7 @@ export class HaDialogHeader extends LitElement {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: var(--ha-dialog-header-white-space, nowrap);
}
.header-title {
height: var(
+30
View File
@@ -39,6 +39,10 @@ export interface IntegrationDescriptions {
};
}
export interface IntegrationFilter {
domains: string[];
}
export const getIntegrationDescriptions = (
hass: HomeAssistant
): Promise<IntegrationDescriptions> =>
@@ -46,6 +50,32 @@ export const getIntegrationDescriptions = (
type: "integration/descriptions",
});
export const filterIntegrationsByDomains = <T extends Brands>(
integrations: T,
domains: string[]
): T => {
const filtered: Brands = {};
for (const [domain, integration] of Object.entries(integrations)) {
if ("integration_type" in integration) {
if (domains.includes(domain)) {
filtered[domain] = integration;
}
continue;
}
const subIntegrations = Object.entries(
integration.integrations ?? {}
).filter(([subDomain]) => domains.includes(subDomain));
if (subIntegrations.length) {
const { iot_standards: _iotStandards, ...brand } = integration;
filtered[domain] = {
...brand,
integrations: Object.fromEntries(subIntegrations),
};
}
}
return filtered as T;
};
export const findIntegration = (
integrations: Brands | undefined,
domain: string
+3
View File
@@ -46,3 +46,6 @@ export const listSerialPortsWithUsage = (hass: { callWS: CallWS }) =>
type: "usb/list_serial_ports",
include_usage: true,
});
export const listSerialIntegrations = (hass: { callWS: CallWS }) =>
hass.callWS<string[]>({ type: "usb/list_serial_integrations" });
@@ -64,7 +64,11 @@ import {
startHassioAddon,
} from "../../data/hassio/addon";
import { extractApiErrorMessage } from "../../data/hassio/common";
import { listSerialPortsWithUsage, type SerialPortUsage } from "../../data/usb";
import {
listSerialIntegrations,
listSerialPortsWithUsage,
type SerialPortUsage,
} from "../../data/usb";
import { showAddIntegrationDialog } from "../../panels/config/integrations/show-add-integration-dialog";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
@@ -712,9 +716,21 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
`;
}
private _setupSerialPort = (ev: Event) => {
private _setupSerialPort = async (ev: Event) => {
ev.stopPropagation();
showAddIntegrationDialog(this);
if (!this._api) {
return;
}
const domains = await listSerialIntegrations(this._api).catch(
() => undefined
);
if (!this.isConnected) {
return;
}
showAddIntegrationDialog(
this,
domains ? { integrationFilter: { domains } } : undefined
);
};
private _dialogTitle(): string {
@@ -45,9 +45,11 @@ import type {
Brand,
Brands,
Integration,
IntegrationFilter,
Integrations,
} from "../../../data/integrations";
import {
filterIntegrationsByDomains,
findIntegration,
getIntegrationDescriptions,
} from "../../../data/integrations";
@@ -95,6 +97,8 @@ class AddIntegrationDialog extends LitElement {
@state() private _filter?: string;
@state() private _integrationFilter?: IntegrationFilter;
@state() private _pickedBrand?: string;
@state() private _prevPickedBrand?: string;
@@ -117,8 +121,10 @@ class AddIntegrationDialog extends LitElement {
private _height?: number;
private _loadId = 0;
public async showDialog(params?: AddIntegrationDialogParams): Promise<void> {
const loadPromise = this._load();
const loadPromise = this._load(params?.integrationFilter);
if (params?.domain) {
// If we get here we clicked the button to add an entry for a specific integration
@@ -150,6 +156,7 @@ class AddIntegrationDialog extends LitElement {
: params?.domain || params?.brand;
this._openedDirectly = !!(params?.brand || params?.domain);
this._initialFilter = params?.initialFilter;
this._integrationFilter = params?.integrationFilter;
this._navigateToResult = params?.navigateToResult ?? false;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -171,8 +178,10 @@ class AddIntegrationDialog extends LitElement {
this._openedDirectly = false;
this._navigateToResult = false;
this._filter = undefined;
this._integrationFilter = undefined;
this._width = undefined;
this._height = undefined;
this._loadId++;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -213,11 +222,12 @@ class AddIntegrationDialog extends LitElement {
components: HassConfig["components"],
localize: LocalizeFunc,
discoveredFlowsCount: number,
filter?: string
filter?: string,
integrationFilter?: IntegrationFilter
): IntegrationListItem[] => {
// Create a single discovered devices row if there are any discovered flows
const discoveredRows: IntegrationListItem[] =
discoveredFlowsCount > 0
!integrationFilter && discoveredFlowsCount > 0
? [
{
id: "_discovered",
@@ -235,7 +245,7 @@ class AddIntegrationDialog extends LitElement {
: [];
const addDeviceRows: IntegrationListItem[] = PROTOCOL_INTEGRATIONS.filter(
(domain) => components.includes(domain)
(domain) => !integrationFilter && components.includes(domain)
)
.map((domain) => ({
id: `device_${domain}`,
@@ -386,7 +396,8 @@ class AddIntegrationDialog extends LitElement {
this.hass.config.components,
this.hass.localize,
this._flowsInProgress?.length ?? 0,
this._filter
this._filter,
this._integrationFilter
);
}
@@ -608,7 +619,8 @@ class AddIntegrationDialog extends LitElement {
`;
};
private async _load() {
private async _load(integrationFilter?: IntegrationFilter) {
const loadId = ++this._loadId;
const [descriptions, flowsInProgress] = await Promise.all([
getIntegrationDescriptions(this.hass),
fetchConfigFlowInProgress(this.hass.connection),
@@ -627,6 +639,10 @@ class AddIntegrationDialog extends LitElement {
await this.hass.loadBackendTranslation("title", discoveredHandlers, true);
}
if (loadId !== this._loadId) {
return;
}
for (const integration in descriptions.custom.integration) {
if (
!Object.prototype.hasOwnProperty.call(
@@ -657,6 +673,16 @@ class AddIntegrationDialog extends LitElement {
...descriptions.core.helper,
...descriptions.custom.helper,
};
if (integrationFilter) {
this._integrations = filterIntegrationsByDomains(
this._integrations,
integrationFilter.domains
);
this._helpers = filterIntegrationsByDomains(
this._helpers,
integrationFilter.domains
);
}
this.hass.loadBackendTranslation(
"title",
descriptions.core.translated_name,
@@ -1,10 +1,12 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { IntegrationManifest } from "../../../data/integration";
import type { IntegrationFilter } from "../../../data/integrations";
export interface AddIntegrationDialogParams {
brand?: string;
domain?: string;
initialFilter?: string;
integrationFilter?: IntegrationFilter;
navigateToResult?: boolean;
}
@@ -1,4 +1,4 @@
import { mdiHistory } from "@mdi/js";
import { mdiCogOutline, mdiHistory } from "@mdi/js";
import type {
HassConfig,
HassEntities,
@@ -31,6 +31,7 @@ import type {
HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-snap-bottom-sheet";
import type { HaSnapBottomSheet } from "../../../../components/ha-snap-bottom-sheet";
@@ -42,6 +43,7 @@ import "../../../../components/ha-state-icon";
import "../../../../components/ha-svg-icon";
import type { HaMapEntity } from "../../../../components/map/ha-map";
import "../../../../components/ha-button";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
import { formatTime } from "../../../../common/datetime/format_time";
import type { ActivityEntry } from "./map-activity";
import { personActivity, zoneActivity } from "./map-activity";
@@ -540,6 +542,17 @@ export class HuiMapOverview extends LitElement {
}
</span>
</div>
${
!__DEMO__ && this._user?.is_admin
? html`<ha-icon-button
.label=${this._i18n.localize(
"ui.dialogs.more_info_control.settings"
)}
.path=${mdiCogOutline}
@click=${this._handleSettings}
></ha-icon-button>`
: nothing
}
</div>
<div class="list">
<div class="activity">
@@ -567,15 +580,6 @@ export class HuiMapOverview extends LitElement {
: this._renderTimeline(stateObj, this._activity)
}
</div>
<ha-button
appearance="filled"
class="more-info"
@click=${this._handleMoreInfo}
>
${this._i18n.localize(
"ui.panel.lovelace.cards.map.overview.more_info"
)}
</ha-button>
</div>
`;
}
@@ -885,9 +889,9 @@ export class HuiMapOverview extends LitElement {
fireEvent(this, "map-overview-select", { entityId: undefined });
}
private _handleMoreInfo() {
private _handleSettings() {
if (this.selected) {
fireEvent(this, "hass-more-info", { entityId: this.selected });
showMoreInfoDialog(this, { entityId: this.selected, view: "settings" });
}
}
@@ -1088,6 +1092,7 @@ export class HuiMapOverview extends LitElement {
}
.detail-title {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
@@ -1192,12 +1197,6 @@ export class HuiMapOverview extends LitElement {
padding-top: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
.more-info {
flex: none;
width: 100%;
margin-top: var(--ha-space-3);
}
`;
}
@@ -1,40 +1,42 @@
import type { ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import {
mdiAlertOctagonOutline,
mdiAlertOutline,
mdiDatabaseExportOutline,
mdiWeb,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { goBack } from "../../../common/navigate";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-base";
import "../../../components/list/ha-list-base";
import { apiContext, internationalizationContext } from "../../../data/context";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import {
acceptMarketplaceWarning,
marketplaceErrorMessage,
} from "../../../data/marketplace/websocket";
const RISKS = [
"not_supported",
"own_risk",
"privacy",
"security",
"stability",
{ risk: "internet", icon: mdiWeb },
{ risk: "data", icon: mdiDatabaseExportOutline },
{ risk: "stability", icon: mdiAlertOctagonOutline },
] as const;
// Long enough to read the risks before they can be accepted, shorter in
// development so it doesn't slow down testing
const READ_SECONDS = __DEV__ ? 5 : 30;
const MINDFUL = ["read", "monitor", "uninstall"] as const;
// Shown over the Marketplace until the user accepts it, the Marketplace stays
// visible behind it but can't be used before then
@customElement("ha-marketplace-warning")
export class HaMarketplaceWarning extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -48,108 +50,97 @@ export class HaMarketplaceWarning extends LitElement {
@state() private _error?: string;
@state() private _secondsLeft = READ_SECONDS;
private _countdown?: number;
public connectedCallback(): void {
super.connectedCallback();
const shownAt = Date.now();
this._secondsLeft = READ_SECONDS;
this._countdown = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - shownAt) / 1000);
this._secondsLeft = Math.max(READ_SECONDS - elapsed, 0);
if (this._secondsLeft === 0) {
this._stopCountdown();
}
}, 1000);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopCountdown();
}
private _stopCountdown(): void {
window.clearInterval(this._countdown);
this._countdown = undefined;
}
// Going back twice would leave the page before the Marketplace as well
private _leaving = false;
protected render() {
return html`
<hass-subpage
.narrow=${this.narrow}
.header=${this._i18n.localize("ui.panel.marketplace.title")}
back-path="/config"
<ha-dialog
open
prevent-scrim-close
.headerTitle=${this._i18n.localize(
"ui.panel.marketplace.warning.title"
)}
>
<div class="content">
<ha-card outlined>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<div class="heading">
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
<h1>
${this._i18n.localize("ui.panel.marketplace.warning.title")}
</h1>
</div>
<p class="intro">
${this._i18n.localize("ui.panel.marketplace.warning.intro")}
</p>
<ha-alert
class="risks"
alert-type="warning"
.title=${this._i18n.localize(
"ui.panel.marketplace.warning.risks_title"
)}
>
<span slot="icon"></span>
<ul>
${RISKS.map(
(risk) =>
html`<li>
${this._i18n.localize(
`ui.panel.marketplace.warning.risks.${risk}`
)}
</li>`
)}
</ul>
</ha-alert>
<ha-checkbox
.checked=${this._understood}
.disabled=${this._accepting}
@change=${this._understoodChanged}
>
${this._i18n.localize("ui.panel.marketplace.warning.understand")}
</ha-checkbox>
</div>
<div class="card-actions">
${
this._secondsLeft > 0
? html`<span class="countdown">
${this._i18n.localize(
"ui.panel.marketplace.warning.continue_in",
{ seconds: this._secondsLeft }
)}
</span>`
: nothing
}
<ha-button
variant="warning"
.disabled=${!this._understood || this._secondsLeft > 0}
.loading=${this._accepting}
@click=${this._accept}
>
${this._i18n.localize("ui.panel.marketplace.warning.continue")}
</ha-button>
</div>
</ha-card>
<ha-svg-icon
slot="headerNavigationIcon"
class="badge"
.path=${mdiAlertOutline}
></ha-svg-icon>
<p class="intro">
${this._i18n.localize("ui.panel.marketplace.warning.intro")}
</p>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<section class="box risks">
<h3>
${this._i18n.localize("ui.panel.marketplace.warning.risks_title")}
</h3>
<ha-list-base>
${RISKS.map(
({ risk, icon }) => html`
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<span slot="content">
${this._i18n.localize(
`ui.panel.marketplace.warning.risks.${risk}`
)}
</span>
</ha-list-item-base>
`
)}
</ha-list-base>
</section>
<section class="box mindful">
<h3>
${this._i18n.localize("ui.panel.marketplace.warning.mindful_title")}
</h3>
<ha-list-base>
${MINDFUL.map(
(step, index) => html`
<ha-list-item-base>
<span slot="start" class="step">${index + 1}</span>
<span slot="content">
${this._i18n.localize(
`ui.panel.marketplace.warning.mindful.${step}`
)}
</span>
</ha-list-item-base>
`
)}
</ha-list-base>
</section>
<div class="box agree">
<ha-checkbox
autofocus
.checked=${this._understood}
.disabled=${this._accepting}
@change=${this._understoodChanged}
>
${this._i18n.localize("ui.panel.marketplace.warning.understand")}
</ha-checkbox>
</div>
</hass-subpage>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._goBack}
>
${this._i18n.localize("ui.panel.marketplace.warning.go_back")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._understood}
.loading=${this._accepting}
@click=${this._accept}
>
${this._i18n.localize("ui.panel.marketplace.warning.continue")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
@@ -157,8 +148,18 @@ export class HaMarketplaceWarning extends LitElement {
this._understood = ev.target.checked;
}
// Neither Escape nor the scrim close the warning, leaving the Marketplace
// takes a deliberate choice here, like accepting it does
private _goBack(): void {
if (this._leaving) {
return;
}
this._leaving = true;
goBack("/config");
}
private async _accept(): Promise<void> {
if (!this._understood || this._secondsLeft > 0 || this._accepting) {
if (!this._understood || this._accepting) {
return;
}
@@ -166,136 +167,94 @@ export class HaMarketplaceWarning extends LitElement {
this._error = undefined;
try {
// The panel removes the warning once it fetches the acceptance, until
// then it stays usable
await acceptMarketplaceWarning(this._api);
} catch (err: unknown) {
this._error = marketplaceErrorMessage(err, this._i18n.localize);
return;
} finally {
// The panel swaps this screen once the backend reports the acceptance,
// until then it stays usable
this._accepting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
height: 100%;
}
static styles = css`
ha-dialog {
--ha-dialog-header-white-space: normal;
--ha-dialog-header-title-height: auto;
}
.content {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100%;
max-width: calc(65ch + 2 * var(--ha-space-6));
margin-inline: auto;
padding: var(--ha-space-4);
}
.badge {
flex: none;
align-self: center;
margin-inline-start: var(--ha-space-4);
margin-inline-end: var(--ha-space-3);
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
background-color: rgba(var(--rgb-warning-color), 0.16);
color: var(--warning-color);
}
ha-card {
border: var(--ha-border-width-lg) solid var(--warning-color);
}
.intro {
margin: 0 0 var(--ha-space-4);
color: var(--secondary-text-color);
}
.card-content {
--card-padding: var(--ha-space-6);
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--card-padding);
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-3);
}
.heading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
.box {
padding: var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
}
.heading ha-svg-icon {
--mdc-icon-size: 96px;
color: var(--warning-color);
}
.box + .box {
margin-top: var(--ha-space-3);
}
h1 {
align-self: stretch;
margin: 0;
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-bold);
line-height: var(--ha-line-height-condensed);
color: var(--warning-color);
}
.risks,
.agree {
background-color: rgba(var(--rgb-warning-color), 0.12);
}
.intro {
margin: 0;
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-normal);
}
.mindful {
border: 1px solid var(--divider-color);
}
ha-alert {
display: block;
}
h3 {
margin: 0 0 var(--ha-space-3);
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
/* Span the full card width, aligning the text with the card content */
ha-alert.risks {
margin-inline: calc(-1 * var(--card-padding));
--ha-alert-icon-size: 0;
--ha-alert-padding: var(--ha-space-4)
calc(var(--card-padding) - var(--ha-space-2));
--ha-border-radius-sm: 0;
}
ha-list-base {
--ha-list-gap: var(--ha-space-3);
}
ul {
margin: 0;
padding-inline-start: var(--ha-space-5);
}
ha-list-item-base {
--ha-row-item-gap: var(--ha-space-3);
--ha-row-item-padding-block: 0;
--ha-row-item-padding-inline: 0;
--ha-row-item-min-height: 0;
}
li {
margin-block: var(--ha-space-2);
font-weight: var(--ha-font-weight-medium);
}
.risks ha-svg-icon {
--mdc-icon-size: 20px;
color: var(--warning-color);
}
ha-checkbox {
font-weight: var(--ha-font-weight-bold);
}
.card-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.countdown {
color: var(--secondary-text-color);
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.content {
padding: var(--ha-space-2);
}
.card-content {
--card-padding: var(--ha-space-4);
}
.heading ha-svg-icon {
--mdc-icon-size: 72px;
}
h1 {
font-size: var(--ha-font-size-2xl);
}
}
`,
];
}
.step {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--secondary-background-color);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
@@ -83,7 +83,7 @@ import {
handleGitHubNotConnected,
handleGitHubRateLimited,
} from "../tools/connect-github";
import { brandsUrl } from "../../../util/brands-url";
import { marketplaceBrandsUrl } from "../../../util/brands-url";
import { generateFrontendResourceURL } from "../tools/frontend-resource";
import { installBlockedReason } from "../tools/install-blocked-reason";
import { typeIcon } from "../tools/type-icon";
@@ -523,7 +523,7 @@ export class HaMarketplaceRepositoryDashboard extends LitElement {
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
src=${marketplaceBrandsUrl(
{
domain: repository.domain,
type: "icon",
+28 -12
View File
@@ -1,7 +1,8 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, html } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import "../../components/ha-button";
@@ -72,6 +73,12 @@ class HaPanelMarketplace extends SubscribeMixin(LitElement) {
protected hassSubscribeRequiredHostProps = ["_integrationLoaded"];
// The catalog, whatever the route is
private _behindWarning = memoizeOne((route: Route): Route => ({
prefix: route.prefix,
path: "/browse",
}));
private _marketplace = memoizeOne(
(
repositories: RepositoryBase[],
@@ -215,19 +222,28 @@ class HaPanelMarketplace extends SubscribeMixin(LitElement) {
`;
}
if (!this._info.warning_accepted) {
return html`
<ha-marketplace-warning .narrow=${this.narrow}></ha-marketplace-warning>
`;
}
const accepted = this._info.warning_accepted;
// The Marketplace shows behind the warning, which keeps it from being
// used. Until it is accepted only the catalog shows there, a link that
// adds a repository or opens a dialog waits for the acceptance, when the
// Marketplace starts over on the route it was opened on.
return html`
<ha-marketplace-router
.hass=${this.hass}
.marketplace=${this._marketplace(this._repositories, this._info)}
.route=${this.route}
.narrow=${this.narrow}
></ha-marketplace-router>
${keyed(
accepted,
html`<ha-marketplace-router
.hass=${this.hass}
.marketplace=${this._marketplace(this._repositories, this._info)}
.route=${accepted ? this.route : this._behindWarning(this.route)}
.narrow=${this.narrow}
?inert=${!accepted}
></ha-marketplace-router>`
)}
${
accepted
? nothing
: html`<ha-marketplace-warning></ha-marketplace-warning>`
}
`;
}
@@ -2,7 +2,7 @@ import { mdiArrowUpCircle, mdiCheckCircle } from "@mdi/js";
import { html, nothing } from "lit";
import "../../../components/ha-svg-icon";
import type { RepositoryBase } from "../../../data/marketplace/repository";
import { brandsUrl } from "../../../util/brands-url";
import { marketplaceBrandsUrl } from "../../../util/brands-url";
import { typeIcon } from "./type-icon";
interface RepositoryIconOptions {
@@ -30,7 +30,7 @@ export const renderRepositoryIcon = (
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
src=${marketplaceBrandsUrl(
{
domain: repository.domain,
type: "icon",
+15 -12
View File
@@ -9594,7 +9594,6 @@
"me": "Me",
"activity": "Activity",
"show_more": "Show more",
"more_info": "More info",
"no_activity": "No recent activity",
"activity_unavailable": "Activity couldn't be loaded",
"entered": "Entered",
@@ -12318,19 +12317,23 @@
"not_loaded": "The Marketplace is not available. It does not load in recovery mode or when it failed to start, check the Home Assistant logs for more information.",
"load_failed": "The Marketplace could not load its data: {error}",
"warning": {
"title": "Before you use the Marketplace",
"intro": "Everything in the Marketplace is made and published by the community. What you install runs inside Home\u00a0Assistant, with full access to your home, your data, and the system Home\u00a0Assistant runs on.",
"risks_title": "Serious risks are involved",
"title": "Before you install from the Marketplace",
"intro": "Marketplace items are made by community contributors and are not reviewed by the Home\u00a0Assistant project. Treat them like plugins for any other software.",
"risks_title": "Anything you install can",
"risks": {
"not_supported": "Nothing published in the Marketplace is tested, audited, or supported by the Home\u00a0Assistant project.",
"own_risk": "You use everything you install from the Marketplace at your own risk.",
"privacy": "It can violate your privacy, for example by sending your data to others.",
"security": "It can compromise the security of Home\u00a0Assistant, your home, and your network.",
"stability": "It can degrade the stability and performance of Home\u00a0Assistant."
"internet": "Connect to the internet",
"data": "Share your data with others",
"stability": "Slow down or crash Home\u00a0Assistant"
},
"understand": "I understand the risks",
"continue": "Continue",
"continue_in": "You can continue in {seconds, plural,\n one {# second}\n other {# seconds}\n}"
"mindful_title": "So be mindful",
"mindful": {
"read": "Read the documentation and understand what the item changes.",
"monitor": "Keep an eye on your system after installing.",
"uninstall": "If something goes wrong, uninstall it right away."
},
"understand": "I understand the risks and agree",
"go_back": "Go back",
"continue": "Continue"
},
"common": {
"install": "Install",
+21 -8
View File
@@ -76,13 +76,19 @@ export const clearBrandsTokenRefresh = (): void => {
}
};
export const brandsUrl = (options: BrandsOptions, hassUrl?: string): string => {
const integrationBrandsUrl = (
category: "integration" | "marketplace",
options: BrandsOptions,
hassUrl?: string
): string => {
const image = `${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
// In the demo there is no backend to serve the token-gated brands API, so
// load the images straight from the public brands CDN instead.
if (__DEMO__) {
return `https://brands.home-assistant.io/${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
return `https://brands.home-assistant.io/${image}`;
}
// The brands API requires a token; without one the request 401s. Return an
@@ -92,15 +98,22 @@ export const brandsUrl = (options: BrandsOptions, hassUrl?: string): string => {
return "";
}
hassUrl = hassUrl ?? location.origin;
const base = `/api/brands/integration/${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
const url = new URL(base, hassUrl);
const url = new URL(`/api/brands/${category}/${image}`, hassUrl);
url.searchParams.set("token", _brandsAccessToken);
return url.toString();
};
export const brandsUrl = (options: BrandsOptions, hassUrl?: string): string =>
integrationBrandsUrl("integration", options, hassUrl);
// Like brandsUrl, but also finds the images an integration ships itself
// before it is installed
export const marketplaceBrandsUrl = (
options: BrandsOptions,
hassUrl?: string
): string => integrationBrandsUrl("marketplace", options, hassUrl);
export const hardwareBrandsUrl = (
options: HardwareBrandsOptions,
hassUrl?: string
+1 -15
View File
@@ -40,11 +40,9 @@ vi.mock("../../../src/data/map_tiles", () => ({
const emitToken = (token: string) =>
tokenListeners.forEach((listener) => listener(token));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
const setWorkerUrl = vi.hoisted(() => vi.fn());
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin, setWorkerUrl }));
vi.mock("maplibre-gl", () => ({ setWorkerUrl }));
// What the build wrote out, which loadStyle fetches instead of building.
const COLORFUL = { palette: "colorful", shipped: "light" } as const;
@@ -257,18 +255,6 @@ describe("createBaseLayer", () => {
);
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
@@ -368,7 +368,6 @@ vi.mock("maplibre-gl", () => ({
AttributionControl: fakes.FakeAttributionControl,
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
setRTLTextPlugin: vi.fn(),
setWorkerUrl: vi.fn(),
}));
@@ -376,7 +375,6 @@ const loadStyle = vi.hoisted(() => vi.fn());
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadStyle,
ensureRTLTextPlugin: vi.fn(),
ensureWorkerUrl: vi.fn(),
}));
+39 -3
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConfigEntryUpdate } from "../../../src/data/config_entries";
import { MarketplaceDispatchEvent } from "../../../src/data/marketplace/common";
import "../../../src/panels/marketplace/ha-panel-marketplace";
import type { HomeAssistant } from "../../../src/types";
import type { HomeAssistant, Route } from "../../../src/types";
// The real screens need more browser than jsdom has, the panel only hands
// them properties.
@@ -170,14 +170,50 @@ describe("ha-panel-marketplace", () => {
);
});
it("shows the warning instead of the Marketplace until it is accepted", async () => {
it("keeps the Marketplace out of use behind the warning until it is accepted", async () => {
const panel = await openPanel({
"marketplace/info": async () => ({ ...INFO, warning_accepted: false }),
"marketplace/repositories/list": async () => [],
});
expect(screen(panel, "ha-marketplace-warning")).not.toBeNull();
expect(screen(panel, "ha-marketplace-router")).toBeNull();
expect(screen(panel, "ha-marketplace-router")!.hasAttribute("inert")).toBe(
true
);
});
it("opens a link only once the warning is accepted, the catalog shows until then", async () => {
let accepted = false;
const panel = await openPanel({
"marketplace/info": async () => ({
...INFO,
warning_accepted: accepted,
}),
"marketplace/repositories/list": async () => [],
});
const link = { prefix: "/marketplace", path: "/repository/42" };
panel.route = link;
await settle(panel);
const behindWarning = screen(panel, "ha-marketplace-router") as
(HTMLElement & { route: Route }) | null;
// A link could add a repository or open a dialog over the warning
expect(behindWarning!.route).toEqual({
prefix: "/marketplace",
path: "/browse",
});
accepted = true;
panel.dispatchEvent(new Event("marketplace-refresh"));
await settle(panel);
// A new router, so what the link does runs once it is accepted
const router = screen(panel, "ha-marketplace-router") as
(HTMLElement & { route: Route }) | null;
expect(router).not.toBe(behindWarning);
expect(router!.route).toBe(link);
expect(router!.hasAttribute("inert")).toBe(false);
expect(screen(panel, "ha-marketplace-warning")).toBeNull();
});
it("loads the translations of the errors of the backend", async () => {
@@ -26,8 +26,6 @@ describe("Marketplace strings", () => {
});
it.each([
["warning.continue_in", { seconds: 1 }, "You can continue in 1 second"],
["warning.continue_in", { seconds: 2 }, "You can continue in 2 seconds"],
[
"repository.community.downloads",
{ number: 1, count: "1" },
+23 -90
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import { goBack } from "../../../src/common/navigate";
import { acceptMarketplaceWarning } from "../../../src/data/marketplace/websocket";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
@@ -11,16 +12,25 @@ const stubElement = vi.hoisted(() => (tag: string) => {
return {};
});
vi.mock("../../../src/layouts/hass-subpage", () => stubElement("hass-subpage"));
vi.mock("../../../src/components/ha-alert", () => stubElement("ha-alert"));
vi.mock("../../../src/components/ha-button", () => stubElement("ha-button"));
vi.mock("../../../src/components/ha-card", () => stubElement("ha-card"));
vi.mock("../../../src/components/ha-checkbox", () =>
stubElement("ha-checkbox")
);
vi.mock("../../../src/components/ha-dialog", () => stubElement("ha-dialog"));
vi.mock("../../../src/components/ha-dialog-footer", () =>
stubElement("ha-dialog-footer")
);
vi.mock("../../../src/components/ha-svg-icon", () =>
stubElement("ha-svg-icon")
);
vi.mock("../../../src/components/item/ha-list-item-base", () =>
stubElement("ha-list-item-base")
);
vi.mock("../../../src/components/list/ha-list-base", () =>
stubElement("ha-list-base")
);
vi.mock("../../../src/common/navigate", () => ({ goBack: vi.fn() }));
vi.mock("../../../src/data/marketplace/websocket", async (importOriginal) => ({
...(await importOriginal<object>()),
acceptMarketplaceWarning: vi.fn(async () => undefined),
@@ -38,40 +48,9 @@ const openWarning = async () => {
return warning;
};
// Only the countdown is faked, Lit renders on microtasks
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
});
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
});
const waitOut = async (
warning: HTMLElement & { updateComplete: Promise<boolean> }
) => {
vi.advanceTimersByTime(30_000);
await warning.updateComplete;
};
const countdown = (warning: HTMLElement) =>
warning.shadowRoot!.querySelector(".card-actions .countdown");
const continueButton = (warning: HTMLElement) =>
warning.shadowRoot!.querySelector(
".card-actions ha-button"
) as HTMLElement & {
disabled: boolean;
};
it("shows its title from the translations of the Marketplace itself", async () => {
const warning = await openWarning();
const subpage = warning.shadowRoot!.querySelector("hass-subpage") as
(HTMLElement & { header: string }) | null;
// A direct visit loads the Marketplace translations, not those of Settings
expect(subpage!.header).toBe("ui.panel.marketplace.title");
vi.clearAllMocks();
});
it("sends the acceptance, and can be continued again after it", async () => {
@@ -83,7 +62,6 @@ it("sends the acceptance, and can be continued again after it", async () => {
})
);
const warning = await openWarning();
await waitOut(warning);
const internals = warning as unknown as Record<string, any>;
internals._understood = true;
@@ -96,84 +74,39 @@ it("sends the acceptance, and can be continued again after it", async () => {
accepted();
await accepting;
// The panel swaps the screen once its refetch works, until then it stays usable
// The panel removes the warning once its refetch works, until then it stays usable
expect(internals._accepting).toBe(false);
expect(internals._error).toBeUndefined();
});
it("sends nothing until the risks are understood", async () => {
const warning = await openWarning();
await waitOut(warning);
await (warning as unknown as Record<string, any>)._accept();
expect(acceptMarketplaceWarning).not.toHaveBeenCalled();
});
it("shows a failure above the warning, on an outlined card", async () => {
it("shows why accepting failed, and can be tried again", async () => {
vi.mocked(acceptMarketplaceWarning).mockRejectedValueOnce(new Error("Busy"));
const warning = await openWarning();
await waitOut(warning);
const internals = warning as unknown as Record<string, any>;
internals._understood = true;
await internals._accept();
await warning.updateComplete;
const content = warning.shadowRoot!.querySelector(".card-content")!;
expect(content.firstElementChild?.getAttribute("alert-type")).toBe("error");
expect(content.firstElementChild?.textContent).toContain("Busy");
expect(internals._error).toContain("Busy");
expect(internals._accepting).toBe(false);
expect(
warning.shadowRoot!.querySelector("ha-card")!.hasAttribute("outlined")
).toBe(true);
});
it("counts down 30 seconds before it can be continued", async () => {
it("goes back only once, however often it is asked to", async () => {
const warning = await openWarning();
const internals = warning as unknown as Record<string, any>;
internals._understood = true;
await warning.updateComplete;
expect(continueButton(warning).disabled).toBe(true);
// A disabled button is hard to read, the countdown stands next to it
expect(continueButton(warning).textContent!.trim()).toBe(
"ui.panel.marketplace.warning.continue"
);
expect(countdown(warning)?.textContent!.trim()).toBe(
"ui.panel.marketplace.warning.continue_in"
);
await internals._accept();
expect(acceptMarketplaceWarning).not.toHaveBeenCalled();
internals._goBack();
internals._goBack();
vi.advanceTimersByTime(29_000);
await warning.updateComplete;
expect(continueButton(warning).disabled).toBe(true);
vi.advanceTimersByTime(1_000);
await warning.updateComplete;
expect(continueButton(warning).disabled).toBe(false);
expect(countdown(warning)).toBeNull();
await internals._accept();
expect(acceptMarketplaceWarning).toHaveBeenCalled();
});
it("stops counting down once it is gone", async () => {
const warning = await openWarning();
expect(vi.getTimerCount()).toBe(1);
warning.remove();
expect(vi.getTimerCount()).toBe(0);
});
it("asks to understand the risks, without a reminder", async () => {
const warning = await openWarning();
expect(
warning.shadowRoot!.querySelector("ha-checkbox")!.textContent!.trim()
).toBe("ui.panel.marketplace.warning.understand");
expect(
warning.shadowRoot!.querySelector('ha-alert[alert-type="info"]')
).toBeNull();
// A second history step would leave the page before the Marketplace too
expect(goBack).toHaveBeenCalledTimes(1);
expect(goBack).toHaveBeenCalledWith("/config");
});
+20
View File
@@ -7,6 +7,7 @@ import {
fetchAndScheduleBrandsAccessToken,
fetchBrandsAccessToken,
hardwareBrandsUrl,
marketplaceBrandsUrl,
scheduleBrandsTokenRefresh,
} from "../../src/util/brands-url";
@@ -27,6 +28,16 @@ describe("Brands URLs without a token", () => {
);
});
it("marketplaceBrandsUrl returns an empty src", () => {
assert.strictEqual(
marketplaceBrandsUrl(
{ domain: "hair", type: "icon" },
"http://homeassistant.local:8123"
),
""
);
});
it("hardwareBrandsUrl returns an empty src", () => {
assert.strictEqual(
hardwareBrandsUrl(
@@ -76,6 +87,15 @@ describe("Generate brands Url", () => {
"http://homeassistant.local:8123/api/brands/integration/cloud/logo.png?token=test-token-123"
);
});
it("Generate Marketplace brands url for an integration", () => {
assert.strictEqual(
marketplaceBrandsUrl(
{ domain: "hair", type: "icon@2x", darkOptimized: true },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/marketplace/hair/[email protected]?token=test-token-123"
);
});
it("Generate icon brands url for cloud component", () => {
assert.strictEqual(
brandsUrl(