diff --git a/src/data/labs.ts b/src/data/labs.ts new file mode 100644 index 000000000000..64071e2b13eb --- /dev/null +++ b/src/data/labs.ts @@ -0,0 +1,78 @@ +import type { Connection } from "home-assistant-js-websocket"; +import { createCollection } from "home-assistant-js-websocket"; +import type { Store } from "home-assistant-js-websocket/dist/store"; +import { debounce } from "../common/util/debounce"; +import type { HomeAssistant } from "../types"; + +export interface LabPreviewFeature { + preview_feature: string; + domain: string; + enabled: boolean; + is_built_in: boolean; + feedback_url?: string; + learn_more_url?: string; + report_issue_url?: string; +} + +export interface LabPreviewFeaturesResponse { + features: LabPreviewFeature[]; +} + +export const fetchLabFeatures = async ( + hass: HomeAssistant +): Promise => { + const response = await hass.callWS({ + type: "labs/list", + }); + return response.features; +}; + +export const labsUpdatePreviewFeature = ( + hass: HomeAssistant, + domain: string, + preview_feature: string, + enabled: boolean, + create_backup?: boolean +): Promise => + hass.callWS({ + type: "labs/update", + domain, + preview_feature, + enabled, + ...(create_backup !== undefined && { create_backup }), + }); + +const fetchLabFeaturesCollection = (conn: Connection) => + conn + .sendMessagePromise({ + type: "labs/list", + }) + .then((response) => response.features); + +const subscribeLabUpdates = ( + conn: Connection, + store: Store +) => + conn.subscribeEvents( + debounce( + () => + fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) => + store.setState(features, true) + ), + 500, + true + ), + "labs_updated" + ); + +export const subscribeLabFeatures = ( + conn: Connection, + onChange: (features: LabPreviewFeature[]) => void +) => + createCollection( + "_labFeatures", + fetchLabFeaturesCollection, + subscribeLabUpdates, + conn, + onChange + ); diff --git a/src/data/translation.ts b/src/data/translation.ts index 3d11ee094f40..b6b9ef24e709 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -72,6 +72,7 @@ export type TranslationCategory = | "system_health" | "application_credentials" | "issues" + | "preview_features" | "selector" | "services" | "triggers"; diff --git a/src/panels/config/core/ha-config-system-navigation.ts b/src/panels/config/core/ha-config-system-navigation.ts index a42719d52d07..1da597e734d5 100644 --- a/src/panels/config/core/ha-config-system-navigation.ts +++ b/src/panels/config/core/ha-config-system-navigation.ts @@ -23,6 +23,8 @@ import { fetchHassioHassOsInfo, fetchHassioHostInfo, } from "../../../data/hassio/host"; +import type { LabPreviewFeature } from "../../../data/labs"; +import { fetchLabFeatures } from "../../../data/labs"; import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; import "../../../layouts/hass-subpage"; import { haStyle } from "../../../resources/styles"; @@ -50,6 +52,8 @@ class HaConfigSystemNavigation extends LitElement { @state() private _externalAccess = false; + @state() private _labFeatures?: LabPreviewFeature[]; + protected render(): TemplateResult { const pages = configSections.general .filter((page) => canShowPage(this.hass, page)) @@ -94,6 +98,12 @@ class HaConfigSystemNavigation extends LitElement { this._boardName || this.hass.localize("ui.panel.config.hardware.description"); break; + case "labs": + description = + this._labFeatures && this._labFeatures.some((f) => f.enabled) + ? this.hass.localize("ui.panel.config.labs.description_enabled") + : this.hass.localize("ui.panel.config.labs.description"); + break; default: description = this.hass.localize( @@ -156,6 +166,7 @@ class HaConfigSystemNavigation extends LitElement { const isHassioLoaded = isComponentLoaded(this.hass, "hassio"); this._fetchBackupInfo(); this._fetchHardwareInfo(isHassioLoaded); + this._fetchLabFeatures(); if (isHassioLoaded) { this._fetchStorageInfo(); } @@ -211,6 +222,12 @@ class HaConfigSystemNavigation extends LitElement { this._externalAccess = this.hass.config.external_url !== null; } + private async _fetchLabFeatures() { + if (isComponentLoaded(this.hass, "labs")) { + this._labFeatures = await fetchLabFeatures(this.hass); + } + } + private async _showRestartDialog() { showRestartDialog(this); } diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index f9e434965cfa..3d990a209721 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -7,6 +7,7 @@ import { mdiCog, mdiDatabase, mdiDevices, + mdiFlask, mdiInformation, mdiInformationOutline, mdiLabel, @@ -328,6 +329,13 @@ export const configSections: Record = { iconPath: mdiShape, iconColor: "#f1c447", }, + { + path: "/config/labs", + translationKey: "labs", + iconPath: mdiFlask, + iconColor: "#b1b134", + core: true, + }, { path: "/config/network", translationKey: "network", @@ -515,6 +523,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) { tag: "ha-config-section-general", load: () => import("./core/ha-config-section-general"), }, + labs: { + tag: "ha-config-labs", + load: () => import("./labs/ha-config-labs"), + }, zha: { tag: "zha-config-dashboard-router", load: () => diff --git a/src/panels/config/labs/dialog-labs-preview-feature-enable.ts b/src/panels/config/labs/dialog-labs-preview-feature-enable.ts new file mode 100644 index 000000000000..3f15cbed9b2e --- /dev/null +++ b/src/panels/config/labs/dialog-labs-preview-feature-enable.ts @@ -0,0 +1,223 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { relativeTime } from "../../../common/datetime/relative_time"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-button"; +import type { HaMdDialog } from "../../../components/ha-md-dialog"; +import "../../../components/ha-md-dialog"; +import "../../../components/ha-md-list"; +import "../../../components/ha-md-list-item"; +import type { HaSwitch } from "../../../components/ha-switch"; +import "../../../components/ha-switch"; +import type { BackupConfig } from "../../../data/backup"; +import { fetchBackupConfig } from "../../../data/backup"; +import type { HassDialog } from "../../../dialogs/make-dialog-manager"; +import type { HomeAssistant } from "../../../types"; +import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable"; + +@customElement("dialog-labs-preview-feature-enable") +export class DialogLabsPreviewFeatureEnable + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: LabsPreviewFeatureEnableDialogParams; + + @state() private _backupConfig?: BackupConfig; + + @state() private _createBackup = false; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public async showDialog( + params: LabsPreviewFeatureEnableDialogParams + ): Promise { + this._params = params; + this._createBackup = false; + await this._fetchBackupConfig(); + } + + public closeDialog(): boolean { + this._dialog?.close(); + return true; + } + + private _dialogClosed(): void { + this._params = undefined; + this._backupConfig = undefined; + this._createBackup = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _fetchBackupConfig() { + try { + const { config } = await fetchBackupConfig(this.hass); + this._backupConfig = config; + + // Default to enabled if automatic backups are configured, disabled otherwise + this._createBackup = + config.automatic_backups_configured && + !!config.create_backup.password && + config.create_backup.agent_ids.length > 0; + } catch { + // User will get manual backup option if fetch fails + this._createBackup = false; + } + } + + private _computeCreateBackupTexts(): + | { title: string; description?: string } + | undefined { + if ( + !this._backupConfig || + !this._backupConfig.automatic_backups_configured || + !this._backupConfig.create_backup.password || + this._backupConfig.create_backup.agent_ids.length === 0 + ) { + return { + title: this.hass.localize("ui.panel.config.labs.create_backup.manual"), + description: this.hass.localize( + "ui.panel.config.labs.create_backup.manual_description" + ), + }; + } + + const lastAutomaticBackupDate = this._backupConfig + .last_completed_automatic_backup + ? new Date(this._backupConfig.last_completed_automatic_backup) + : null; + const now = new Date(); + + return { + title: this.hass.localize("ui.panel.config.labs.create_backup.automatic"), + description: lastAutomaticBackupDate + ? this.hass.localize( + "ui.panel.config.labs.create_backup.automatic_description_last", + { + relative_time: relativeTime( + lastAutomaticBackupDate, + this.hass.locale, + now, + true + ), + } + ) + : this.hass.localize( + "ui.panel.config.labs.create_backup.automatic_description_none" + ), + }; + } + + private _createBackupChanged(ev: Event): void { + this._createBackup = (ev.target as HaSwitch).checked; + } + + private _handleCancel(): void { + this.closeDialog(); + } + + private _handleConfirm(): void { + if (this._params) { + this._params.onConfirm(this._createBackup); + } + this.closeDialog(); + } + + protected render() { + if (!this._params) { + return nothing; + } + + const createBackupTexts = this._computeCreateBackupTexts(); + + return html` + + + ${this.hass.localize("ui.panel.config.labs.enable_title")} + +
+

+ ${this.hass.localize( + `component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation` + ) || this.hass.localize("ui.panel.config.labs.enable_confirmation")} +

+
+
+ ${createBackupTexts + ? html` + + + ${createBackupTexts.title} + ${createBackupTexts.description + ? html` + + ${createBackupTexts.description} + + ` + : nothing} + + + + ` + : nothing} +
+ + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.panel.config.labs.enable")} + +
+
+
+ `; + } + + static readonly styles = css` + ha-md-dialog { + --dialog-content-padding: var(--ha-space-6); + } + + p { + margin: 0; + color: var(--secondary-text-color); + } + + div[slot="actions"] { + display: flex; + flex-direction: column; + padding: 0; + } + + ha-md-list { + background: none; + --md-list-item-leading-space: var(--ha-space-6); + --md-list-item-trailing-space: var(--ha-space-6); + margin: 0; + padding: 0; + border-top: 1px solid var(--divider-color); + } + + div[slot="actions"] > div { + display: flex; + justify-content: flex-end; + gap: var(--ha-space-2); + padding: var(--ha-space-4) var(--ha-space-6); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-labs-preview-feature-enable": DialogLabsPreviewFeatureEnable; + } +} diff --git a/src/panels/config/labs/dialog-labs-progress.ts b/src/panels/config/labs/dialog-labs-progress.ts new file mode 100644 index 000000000000..7a200a79db0e --- /dev/null +++ b/src/panels/config/labs/dialog-labs-progress.ts @@ -0,0 +1,113 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-md-dialog"; +import "../../../components/ha-spinner"; +import type { HassDialog } from "../../../dialogs/make-dialog-manager"; +import type { HomeAssistant } from "../../../types"; +import type { LabsProgressDialogParams } from "./show-dialog-labs-progress"; + +@customElement("dialog-labs-progress") +export class DialogLabsProgress + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: LabsProgressDialogParams; + + @state() private _open = false; + + public async showDialog(params: LabsProgressDialogParams): Promise { + this._params = params; + this._open = true; + } + + public closeDialog(): boolean { + this._open = false; + return true; + } + + private _handleClosed(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + +
+
+ +
+

+ ${this.hass.localize( + "ui.panel.config.labs.progress.creating_backup" + )} +

+

+ ${this.hass.localize( + this._params.enabled + ? "ui.panel.config.labs.progress.backing_up_before_enabling" + : "ui.panel.config.labs.progress.backing_up_before_disabling" + )} +

+
+
+
+
+ `; + } + + static readonly styles = css` + ha-md-dialog { + --dialog-content-padding: var(--ha-space-6); + } + + .summary { + display: flex; + flex-direction: row; + column-gap: var(--ha-space-4); + align-items: center; + justify-content: center; + padding: var(--ha-space-4) 0; + } + ha-spinner { + --ha-spinner-size: 60px; + flex-shrink: 0; + } + .content { + flex: 1; + min-width: 0; + } + .heading { + font-size: var(--ha-font-size-xl); + line-height: var(--ha-line-height-condensed); + color: var(--primary-text-color); + margin: 0 0 var(--ha-space-1); + } + .description { + font-size: var(--ha-font-size-m); + line-height: var(--ha-line-height-condensed); + letter-spacing: 0.25px; + color: var(--secondary-text-color); + margin: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-labs-progress": DialogLabsProgress; + } +} diff --git a/src/panels/config/labs/ha-config-labs.ts b/src/panels/config/labs/ha-config-labs.ts new file mode 100644 index 000000000000..73d3c63d5470 --- /dev/null +++ b/src/panels/config/labs/ha-config-labs.ts @@ -0,0 +1,549 @@ +import { mdiFlask, mdiHelpCircle, mdiOpenInNew } from "@mdi/js"; +import type { PropertyValues, TemplateResult } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import { extractSearchParam } from "../../../common/url/search-params"; +import { domainToName } from "../../../data/integration"; +import { + labsUpdatePreviewFeature, + subscribeLabFeatures, +} from "../../../data/labs"; +import type { LabPreviewFeature } from "../../../data/labs"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { brandsUrl } from "../../../util/brands-url"; +import { showToast } from "../../../util/toast"; +import { haStyle } from "../../../resources/styles"; +import { showLabsPreviewFeatureEnableDialog } from "./show-dialog-labs-preview-feature-enable"; +import { + showLabsProgressDialog, + closeLabsProgressDialog, +} from "./show-dialog-labs-progress"; +import "../../../components/ha-alert"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-markdown"; +import "../../../components/ha-switch"; +import "../../../layouts/hass-subpage"; + +@customElement("ha-config-labs") +class HaConfigLabs extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @state() private _preview_features: LabPreviewFeature[] = []; + + @state() private _highlightedPreviewFeature?: string; + + private _sortedPreviewFeatures = memoizeOne( + (localize: LocalizeFunc, features: LabPreviewFeature[]) => + // Sort by localized integration name alphabetically + [...features].sort((a, b) => + domainToName(localize, a.domain).localeCompare( + domainToName(localize, b.domain) + ) + ) + ); + + public hassSubscribe() { + return [ + subscribeLabFeatures(this.hass.connection, (features) => { + // Load title translations for integrations with preview features + const domains = [...new Set(features.map((f) => f.domain))]; + this.hass.loadBackendTranslation("title", domains); + + this._preview_features = features; + }), + ]; + } + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + // Load preview_features translations + this.hass.loadBackendTranslation("preview_features"); + this._handleUrlParams(); + } + + private _handleUrlParams(): void { + // Check for feature parameters in URL + const domain = extractSearchParam("domain"); + const previewFeature = extractSearchParam("preview_feature"); + if (domain && previewFeature) { + const previewFeatureId = `${domain}.${previewFeature}`; + this._highlightedPreviewFeature = previewFeatureId; + // Wait for next render to ensure cards are in DOM + this.updateComplete.then(() => { + this._scrollToPreviewFeature(previewFeatureId); + }); + } + } + + protected render() { + const sortedFeatures = this._sortedPreviewFeatures( + this.hass.localize, + this._preview_features + ); + + return html` + + ${sortedFeatures.length + ? html` + + + + ` + : nothing} +
+ ${!sortedFeatures.length + ? html` +
+ +

+ ${this.hass.localize("ui.panel.config.labs.empty.title")} +

+ ${this.hass.localize( + "ui.panel.config.labs.empty.description" + )} + + ${this.hass.localize("ui.panel.config.labs.learn_more")} + + +
+ ` + : html` + +
+

+ ${this.hass.localize("ui.panel.config.labs.intro_title")} +

+

+ ${this.hass.localize( + "ui.panel.config.labs.intro_description" + )} +

+ + ${this.hass.localize( + "ui.panel.config.labs.intro_warning" + )} + +
+
+ + ${sortedFeatures.map((preview_feature) => + this._renderPreviewFeature(preview_feature) + )} + `} +
+
+ `; + } + + private _renderPreviewFeature( + preview_feature: LabPreviewFeature + ): TemplateResult { + const featureName = this.hass.localize( + `component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.name` + ); + + const description = this.hass.localize( + `component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.description` + ); + + const integrationName = domainToName( + this.hass.localize, + preview_feature.domain + ); + + const integrationNameWithCustomLabel = !preview_feature.is_built_in + ? `${integrationName} • ${this.hass.localize("ui.panel.config.labs.custom_integration")}` + : integrationName; + + const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`; + const isHighlighted = this._highlightedPreviewFeature === previewFeatureId; + + // Build description with learn more link if available + const descriptionWithLink = preview_feature.learn_more_url + ? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})` + : description; + + return html` + +
+
+ +
+ ${integrationNameWithCustomLabel} +

${featureName}

+
+
+ +
+
+
+ ${preview_feature.feedback_url + ? html` + + ${this.hass.localize( + "ui.panel.config.labs.provide_feedback" + )} + + ` + : nothing} + ${preview_feature.report_issue_url + ? html` + + ${this.hass.localize("ui.panel.config.labs.report_issue")} + + ` + : nothing} +
+ + ${this.hass.localize( + preview_feature.enabled + ? "ui.panel.config.labs.disable" + : "ui.panel.config.labs.enable" + )} + +
+
+ `; + } + + private _scrollToPreviewFeature(previewFeatureId: string): void { + const card = this.shadowRoot?.querySelector( + `[data-feature-id="${previewFeatureId}"]` + ) as HTMLElement; + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "center" }); + // Clear highlight after animation + setTimeout(() => { + this._highlightedPreviewFeature = undefined; + }, 3000); + } + } + + private async _handleToggle(ev: Event): Promise { + const buttonEl = ev.currentTarget as HTMLElement & { + preview_feature: LabPreviewFeature; + }; + const preview_feature = buttonEl.preview_feature; + const enabled = !preview_feature.enabled; + const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`; + + if (enabled) { + // Show custom enable dialog with backup option + showLabsPreviewFeatureEnableDialog(this, { + preview_feature, + previewFeatureId, + onConfirm: async (shouldCreateBackup) => { + await this._performToggle( + previewFeatureId, + enabled, + shouldCreateBackup + ); + }, + }); + return; + } + + // Show simple confirmation dialog for disable + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize("ui.panel.config.labs.disable_title"), + text: + this.hass.localize( + `component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.disable_confirmation` + ) || this.hass.localize("ui.panel.config.labs.disable_confirmation"), + confirmText: this.hass.localize("ui.panel.config.labs.disable"), + dismissText: this.hass.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + + await this._performToggle(previewFeatureId, enabled, false); + } + + private async _performToggle( + previewFeatureId: string, + enabled: boolean, + createBackup: boolean + ): Promise { + if (createBackup) { + showLabsProgressDialog(this, { enabled }); + } + + const parts = previewFeatureId.split(".", 2); + if (parts.length !== 2) { + showToast(this, { + message: this.hass.localize("ui.common.unknown_error"), + }); + return; + } + const [domain, preview_feature] = parts; + + try { + await labsUpdatePreviewFeature( + this.hass, + domain, + preview_feature, + enabled, + createBackup + ); + } catch (err: any) { + if (createBackup) { + closeLabsProgressDialog(); + } + const errorMessage = + err?.message || this.hass.localize("ui.common.unknown_error"); + showToast(this, { + message: this.hass.localize( + enabled + ? "ui.panel.config.labs.enable_failed" + : "ui.panel.config.labs.disable_failed", + { error: errorMessage } + ), + }); + return; + } + + // Close dialog before showing success toast + if (createBackup) { + closeLabsProgressDialog(); + } + + // Show success toast - collection will auto-update via labs_updated event + showToast(this, { + message: this.hass.localize( + enabled + ? "ui.panel.config.labs.enabled_success" + : "ui.panel.config.labs.disabled_success" + ), + }); + } + + static styles = [ + haStyle, + css` + :host { + display: block; + } + + .content { + max-width: 800px; + margin: 0 auto; + padding: 16px; + min-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + } + + .content:has(.empty) { + justify-content: center; + } + + ha-card { + margin-bottom: 16px; + position: relative; + transition: box-shadow 0.3s ease; + } + + ha-card.highlighted { + animation: highlight-fade 2.5s ease-out forwards; + } + + @keyframes highlight-fade { + 0% { + box-shadow: + 0 0 0 2px var(--primary-color), + 0 0 12px rgba(var(--rgb-primary-color), 0.4); + } + 100% { + box-shadow: + 0 0 0 2px transparent, + 0 0 0 transparent; + } + } + + /* Intro card */ + .intro-card { + display: flex; + flex-direction: column; + gap: 16px; + } + + .intro-card h1 { + margin: 0; + } + + .intro-text { + margin: 0 0 12px; + } + + /* Feature cards */ + .card-content { + padding: 16px; + } + + .card-header { + display: flex; + gap: 12px; + margin-bottom: 16px; + align-items: flex-start; + } + + .card-header img { + width: 38px; + height: 38px; + flex-shrink: 0; + margin-top: 2px; + } + + .feature-title { + flex: 1; + min-width: 0; + } + + .feature-title h2 { + margin: 0; + line-height: 1.3; + } + + .integration-name { + display: block; + margin-bottom: 2px; + font-size: 14px; + color: var(--secondary-text-color); + } + + /* Empty state */ + .empty { + max-width: 500px; + margin: 0 auto; + padding: 48px 16px; + text-align: center; + } + + .empty ha-svg-icon { + width: 120px; + height: 120px; + color: var(--secondary-text-color); + opacity: 0.3; + } + + .empty h1 { + margin: 24px 0 16px; + } + + .empty p { + margin: 0 0 24px; + font-size: 16px; + line-height: 24px; + color: var(--secondary-text-color); + } + + .empty a { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--primary-color); + text-decoration: none; + font-weight: 500; + } + + .empty a:hover { + text-decoration: underline; + } + + .empty a:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + + .empty a ha-svg-icon { + width: 16px; + height: 16px; + opacity: 1; + } + + /* Card actions */ + .card-actions { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + border-top: 1px solid var(--divider-color); + } + + .card-actions > div { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-labs": HaConfigLabs; + } +} diff --git a/src/panels/config/labs/show-dialog-labs-preview-feature-enable.ts b/src/panels/config/labs/show-dialog-labs-preview-feature-enable.ts new file mode 100644 index 000000000000..79e616a015c3 --- /dev/null +++ b/src/panels/config/labs/show-dialog-labs-preview-feature-enable.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import type { LabPreviewFeature } from "../../../data/labs"; + +export interface LabsPreviewFeatureEnableDialogParams { + preview_feature: LabPreviewFeature; + previewFeatureId: string; + onConfirm: (createBackup: boolean) => void; +} + +export const loadLabsPreviewFeatureEnableDialog = () => + import("./dialog-labs-preview-feature-enable"); + +export const showLabsPreviewFeatureEnableDialog = ( + element: HTMLElement, + params: LabsPreviewFeatureEnableDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-labs-preview-feature-enable", + dialogImport: loadLabsPreviewFeatureEnableDialog, + dialogParams: params, + }); +}; diff --git a/src/panels/config/labs/show-dialog-labs-progress.ts b/src/panels/config/labs/show-dialog-labs-progress.ts new file mode 100644 index 000000000000..ee68fc2097ef --- /dev/null +++ b/src/panels/config/labs/show-dialog-labs-progress.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { closeDialog } from "../../../dialogs/make-dialog-manager"; + +export interface LabsProgressDialogParams { + enabled: boolean; +} + +export const loadLabsProgressDialog = () => import("./dialog-labs-progress"); + +export const showLabsProgressDialog = ( + element: HTMLElement, + dialogParams: LabsProgressDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-labs-progress", + dialogImport: loadLabsProgressDialog, + dialogParams, + }); +}; + +export const closeLabsProgressDialog = () => + closeDialog("dialog-labs-progress"); diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 4878250f9ef8..b9b504f90aab 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -188,6 +188,13 @@ export const getMyRedirects = (): Redirects => ({ helpers: { redirect: "/config/helpers", }, + labs: { + redirect: "/config/labs", + params: { + domain: "string?", + preview_feature: "string?", + }, + }, tags: { component: "tag", redirect: "/config/tags", diff --git a/src/translations/en.json b/src/translations/en.json index 5f6c77d57f3c..adf7c31ab957 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6784,6 +6784,45 @@ "intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.", "download_device_info": "Preview device analytics" }, + "labs": { + "caption": "Labs", + "custom_integration": "Custom integration", + "description": "Preview new features", + "description_enabled": "Preview features are enabled", + "intro_title": "Home Assistant Labs", + "intro_subtitle": "Preview upcoming features", + "intro_description": "Home Assistant Labs lets you preview new features we're actively working on. These features are stable and fully functional, but may not yet include the complete feature set we envision. We're still refining the design and approach based on your feedback.", + "intro_warning": "Preview features may change or be replaced with different solutions in future releases.", + "empty": { + "title": "No preview features available", + "description": "There are currently no preview features available to try. Check back in future releases for new features!" + }, + "learn_more": "Learn more", + "provide_feedback": "Provide feedback", + "report_issue": "Report issue", + "enable": "Enable", + "disable": "Disable", + "enable_title": "Enable preview feature?", + "enable_confirmation": "This preview feature is stable but may evolve based on feedback. Enabling it may affect your setup, and changes may persist after disabling.", + "disable_title": "Disable preview feature?", + "disable_confirmation": "This will disable the preview feature, but changes made while it was active may still affect your setup.", + "enabled_success": "Preview feature enabled", + "disabled_success": "Preview feature disabled", + "enable_failed": "Enabling preview feature failed: {error}", + "disable_failed": "Disabling preview feature failed: {error}", + "progress": { + "creating_backup": "Creating backup", + "backing_up_before_enabling": "Home Assistant is being backed up before enabling the Home Assistant Labs preview feature", + "backing_up_before_disabling": "Home Assistant is being backed up before disabling the Home Assistant Labs preview feature" + }, + "create_backup": { + "automatic": "Automatic backup before enabling", + "automatic_description_last": "Last automatic backup {relative_time}.", + "automatic_description_none": "No automatic backup yet.", + "manual": "Create manual backup before enabling", + "manual_description": "Includes Home Assistant settings and history." + } + }, "network": { "caption": "Network", "description": "External access {state}",