mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-08 22:24:15 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4a8e0a9 | ||
|
|
480182c2b3 | ||
|
|
2d11eba37f | ||
|
|
5821cc6e53 | ||
|
|
ade5d15fee | ||
|
|
b8ea4d12b9 |
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-13
@@ -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,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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user