mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-22 11:06:55 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2c69ab7f | |||
| 7543370954 | |||
| 39f5ab2746 | |||
| 58ded99773 | |||
| 0d2b99b3a5 | |||
| 4be7a32148 |
@@ -124,7 +124,7 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
|
||||
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
|
||||
type: "frontend/loaded"; // Fired once the launch screen is removed; with hasSplashscreen this is after the first panel has rendered
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageAppConfiguration extends EMMessage {
|
||||
@@ -371,6 +371,7 @@ export interface ExternalConfig {
|
||||
appVersion?: string;
|
||||
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
|
||||
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
|
||||
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
|
||||
}
|
||||
|
||||
export interface ExternalEntityAddToAction {
|
||||
|
||||
@@ -133,7 +133,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
) {
|
||||
this.render = this.renderHass;
|
||||
this.update = super.update;
|
||||
removeLaunchScreen();
|
||||
// Apps with a native splash screen keep covering the frontend until
|
||||
// frontend/loaded, so the launch screen stays up (invisibly) until the
|
||||
// first panel has rendered and partial-panel-resolver removes it.
|
||||
if (!this.hass.auth.external?.config.hasSplashscreen) {
|
||||
removeLaunchScreen();
|
||||
}
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
super.update(changedProps);
|
||||
|
||||
@@ -220,7 +220,8 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
) {
|
||||
await this.rebuild();
|
||||
await this.pageRendered;
|
||||
removeLaunchScreen();
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,15 @@ export class HuiEnergyUsageGraphCard
|
||||
to_battery: {},
|
||||
};
|
||||
|
||||
// Grid sources can be import-only or export-only; assign color indices by
|
||||
// position in the user's grid sources config so this card matches the
|
||||
// energy sources table, which uses positional indices.
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
});
|
||||
let gridIdx = 0;
|
||||
|
||||
for (const source of energyData.prefs.energy_sources) {
|
||||
if (source.type === "solar") {
|
||||
if (statIds.solar) {
|
||||
@@ -335,6 +344,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.from_grid = [gridSource.stat_energy_from];
|
||||
}
|
||||
colorIndices.from_grid[gridSource.stat_energy_from] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.from_grid[gridSource.stat_energy_from] =
|
||||
gridSource.stat_energy_to
|
||||
@@ -351,6 +361,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.to_grid = [gridSource.stat_energy_to];
|
||||
}
|
||||
colorIndices.to_grid[gridSource.stat_energy_to] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.to_grid[gridSource.stat_energy_to] =
|
||||
gridSource.stat_energy_from
|
||||
@@ -361,17 +372,18 @@ export class HuiEnergyUsageGraphCard
|
||||
: gridSource.name;
|
||||
}
|
||||
}
|
||||
gridIdx++;
|
||||
}
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
if (
|
||||
key === "used_grid" ||
|
||||
key === "used_solar" ||
|
||||
key === "used_battery"
|
||||
key === "used_battery" ||
|
||||
key === "from_grid" ||
|
||||
key === "to_grid"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,27 @@ import { render } from "lit";
|
||||
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
|
||||
import { withViewTransition } from "../common/util/view-transition";
|
||||
|
||||
export const removeLaunchScreen = () => {
|
||||
let removalInitiated = false;
|
||||
|
||||
/**
|
||||
* Removes the launch screen with a fade-out view transition.
|
||||
*
|
||||
* @param instant - Removes the launch screen without animation. Used when the
|
||||
* external app covers the frontend with its own splash screen until the
|
||||
* `frontend/loaded` event, where the animation would play invisibly underneath.
|
||||
* @returns Whether this call initiated the removal (false when the removal
|
||||
* was already initiated, e.g. while the fade-out is still running).
|
||||
*/
|
||||
export const removeLaunchScreen = (instant = false): boolean => {
|
||||
const launchScreenElement = document.getElementById("ha-launch-screen");
|
||||
if (!launchScreenElement?.parentElement) {
|
||||
return;
|
||||
if (removalInitiated || !launchScreenElement?.parentElement) {
|
||||
return false;
|
||||
}
|
||||
removalInitiated = true;
|
||||
|
||||
if (instant) {
|
||||
launchScreenElement.parentElement.removeChild(launchScreenElement);
|
||||
return true;
|
||||
}
|
||||
|
||||
withViewTransition((viewTransitionAvailable: boolean) => {
|
||||
@@ -25,6 +42,7 @@ export const removeLaunchScreen = () => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
}, parseAnimationDuration(durationFromCss));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const renderLaunchScreenInfoBox = (content: TemplateResult) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const SURVEY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month
|
||||
// version-specific (the version is passed as a query parameter instead), so
|
||||
// beta/dev installs should not be routed to rc./next. like documentationUrl
|
||||
// would.
|
||||
const SURVEY_URL = "https://www.home-assistant.io/surveys/onboarding";
|
||||
const SURVEY_URL = "https://ohf.to/ha/surveys/onboarding";
|
||||
|
||||
export const shouldShowOnboardingSurvey = (
|
||||
user: CurrentUser | undefined,
|
||||
|
||||
+69
-6
@@ -10,9 +10,12 @@ import {
|
||||
import {
|
||||
activateDemoSidebarPanel,
|
||||
demoCardSelector,
|
||||
expectDemoDarkMode,
|
||||
expectStoredDemoTheme,
|
||||
loadDemo,
|
||||
moreInfoCardSelector,
|
||||
openDemoSidebar,
|
||||
waitForDemoReady,
|
||||
reloadDemo,
|
||||
} from "./demo/helpers";
|
||||
|
||||
test.describe("Home Assistant Demo", () => {
|
||||
@@ -20,17 +23,16 @@ test.describe("Home Assistant Demo", () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = trackPageErrors(page);
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
await loadDemo(page);
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
test("dashboard renders Lovelace cards", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
await loadDemo(page);
|
||||
|
||||
await expect(page.locator(demoCardSelector).first()).toBeVisible({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
@@ -38,7 +40,7 @@ test.describe("Home Assistant Demo", () => {
|
||||
});
|
||||
|
||||
test("sidebar navigation changes the active panel", async ({ page }) => {
|
||||
await waitForDemoReady(page);
|
||||
await loadDemo(page);
|
||||
await openDemoSidebar(page);
|
||||
await activateDemoSidebarPanel(page, "map");
|
||||
|
||||
@@ -48,7 +50,7 @@ test.describe("Home Assistant Demo", () => {
|
||||
test("clicking an entity card opens the more-info dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await waitForDemoReady(page);
|
||||
await loadDemo(page);
|
||||
|
||||
// Tile cards are the most common card type in the demo; fall back to other
|
||||
// clickable card types in case this platform renders a different layout.
|
||||
@@ -65,4 +67,65 @@ test.describe("Home Assistant Demo", () => {
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
test(`unset theme remains light with ${colorScheme} system color scheme`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem("demo_theme");
|
||||
localStorage.removeItem("selectedTheme");
|
||||
});
|
||||
|
||||
await loadDemo(page);
|
||||
await expectDemoDarkMode(page, false);
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
}
|
||||
|
||||
test("theme selection persists without offering migration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
if (sessionStorage.getItem("theme_test_seeded")) {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem("theme_test_seeded", "true");
|
||||
localStorage.removeItem("demo_theme");
|
||||
localStorage.setItem(
|
||||
"selectedTheme",
|
||||
JSON.stringify({ theme: "default", dark: false })
|
||||
);
|
||||
});
|
||||
|
||||
await loadDemo(page, "/#/profile/general");
|
||||
|
||||
const themeRow = page.locator("ha-pick-theme-row");
|
||||
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
|
||||
await expect(themeRow.locator(":scope > ha-settings-row")).toHaveCount(0);
|
||||
|
||||
await themeRow.locator('ha-radio-option[value="dark"]').click();
|
||||
|
||||
await expectStoredDemoTheme(page, { theme: "default", dark: true });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem("selectedTheme")), {
|
||||
timeout: SHELL_TIMEOUT,
|
||||
})
|
||||
.toBeNull();
|
||||
await expectDemoDarkMode(page, true);
|
||||
|
||||
await reloadDemo(page);
|
||||
await expectStoredDemoTheme(page, { theme: "default", dark: true });
|
||||
await expectDemoDarkMode(page, true);
|
||||
|
||||
await loadDemo(page, "/#/profile/general");
|
||||
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
|
||||
await expect(
|
||||
themeRow.locator('ha-radio-option[value="dark"]')
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
expectNoPageErrors(pageErrors);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
|
||||
|
||||
const DEMO_THEME_STORAGE_KEY = "demo_theme";
|
||||
|
||||
export const demoCardSelector = [
|
||||
"hui-tile-card",
|
||||
"hui-entity-card",
|
||||
@@ -21,6 +24,47 @@ export async function waitForDemoReady(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadDemo(page: Page, path = "/") {
|
||||
await page.goto(path);
|
||||
await waitForDemoReady(page);
|
||||
}
|
||||
|
||||
export async function reloadDemo(page: Page) {
|
||||
await page.reload();
|
||||
await waitForDemoReady(page);
|
||||
}
|
||||
|
||||
export async function expectDemoDarkMode(page: Page, darkMode: boolean) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.locator("ha-demo").evaluate((element) => {
|
||||
const demo = element as HTMLElement & {
|
||||
hass?: { themes?: { darkMode?: boolean } };
|
||||
};
|
||||
return demo.hass?.themes?.darkMode;
|
||||
}),
|
||||
{ timeout: SHELL_TIMEOUT }
|
||||
)
|
||||
.toBe(darkMode);
|
||||
}
|
||||
|
||||
export async function expectStoredDemoTheme(
|
||||
page: Page,
|
||||
expected: ThemeSettings
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((storageKey) => {
|
||||
const storedTheme = localStorage.getItem(storageKey);
|
||||
return storedTheme ? (JSON.parse(storedTheme) as unknown) : null;
|
||||
}, DEMO_THEME_STORAGE_KEY),
|
||||
{ timeout: SHELL_TIMEOUT }
|
||||
)
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
export async function openDemoSidebar(page: Page) {
|
||||
const menuButton = page.locator("ha-menu-button");
|
||||
if (await menuButton.isVisible()) {
|
||||
|
||||
@@ -175,14 +175,14 @@ describe("getOnboardingSurveyUrl", () => {
|
||||
onboarded_version: "2026.7.0",
|
||||
onboarded_date: "2026-07-14T12:00:00.000Z",
|
||||
}),
|
||||
"https://www.home-assistant.io/surveys/onboarding?version=2026.7.0"
|
||||
"https://ohf.to/ha/surveys/onboarding?version=2026.7.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits a missing version param", () => {
|
||||
assert.strictEqual(
|
||||
getOnboardingSurveyUrl({}),
|
||||
"https://www.home-assistant.io/surveys/onboarding"
|
||||
"https://ohf.to/ha/surveys/onboarding"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user