Compare commits

...
9 changed files with 290 additions and 46 deletions

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import {
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// `filter` is the name the search had before, still read until 2027.5. It is
// in the configs so writing the search replaces it.
export const logSearchQueryParamConfig = {
string: ["search", "filter"],
} as const satisfies QueryParamConfig;
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type LogsQueryParams = QueryParamValues<typeof logsQueryParamConfig>;
export const DEFAULT_LOG_PROVIDER = "core";
export const logSearchFromUrl = (searchParams: SearchParamsSource): string => {
const { search, filter } = decodeQueryParams(
searchParams,
logSearchQueryParamConfig
);
return search ?? filter ?? "";
};
export const logProviderFromUrl = (searchParams: SearchParamsSource): string =>
decodeQueryParams(searchParams, logsQueryParamConfig).provider ??
DEFAULT_LOG_PROVIDER;
export const logRawFromUrl = (searchParams: SearchParamsSource): boolean =>
decodeQueryParams(searchParams, logsQueryParamConfig).raw ?? false;
/**
* Params for the logs page. The default provider and no search are left out,
* and raw only applies to Core, the other providers only have raw logs.
*/
export const logsQueryParamsFromState = (
provider: string,
search: string,
raw = false
): LogsQueryParams => ({
provider: provider === DEFAULT_LOG_PROVIDER ? undefined : provider,
search,
raw: provider === DEFAULT_LOG_PROVIDER && raw,
});
@@ -3,12 +3,19 @@ import {
type CSSResultGroup,
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../../../../common/url/search-params";
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
import {
logSearchFromUrl,
logSearchQueryParamConfig,
} from "../../../../../common/url/logs-query-params";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { HassioAddonDetails } from "../../../../../data/hassio/addon";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -21,7 +28,24 @@ class SupervisorAppLogDashboard extends LitElement {
@property({ attribute: false }) public addon?: HassioAddonDetails;
@state() private _filter = extractSearchParam("filter") || "";
@state() private _filter = "";
private _queryParams = new QueryParamsController(
this,
logSearchQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyUrl(),
}
);
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._applyUrl();
}
}
protected render(): TemplateResult {
if (!this.addon) {
@@ -32,6 +56,7 @@ class SupervisorAppLogDashboard extends LitElement {
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
@blur=${this._flushUrl}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
@@ -47,8 +72,25 @@ class SupervisorAppLogDashboard extends LitElement {
`;
}
private async _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HTMLInputElement).value;
private _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
this._queryParams.write({ search: this._filter }, true);
}
private _flushUrl() {
this._queryParams.flush();
}
// A link without a search clears it, like the logs page
private _applyUrl() {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
this._filter = logSearchFromUrl(searchParams);
this._queryParams.write({ search: this._filter });
}
static get styles(): CSSResultGroup {
@@ -7,6 +7,7 @@ import {
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { goBack, navigate } from "../../../common/navigate";
@@ -140,14 +141,17 @@ class HaConfigAppDashboard extends LitElement {
back-path=${this._fromStore ? "/config/apps/available" : "/config/apps"}
>
<span slot="header">${this._addon.name}</span>
<supervisor-app-router
.route=${route}
.narrow=${this.narrow}
.hass=${this.hass}
.addon=${this._addon}
.controlEnabled=${this._controlEnabled}
@system-managed-take-control=${this._enableControl}
></supervisor-app-router>
${keyed(
this._addon.slug,
html`<supervisor-app-router
.route=${route}
.narrow=${this.narrow}
.hass=${this.hass}
.addon=${this._addon}
.controlEnabled=${this._controlEnabled}
@system-managed-take-control=${this._enableControl}
></supervisor-app-router>`
)}
</hass-tabs-subpage>
`;
}
@@ -321,7 +321,7 @@ export class HaConfigEntryRow extends LitElement {
ERROR_STATES.includes(item.state)
? html`
<a
href=${`/config/logs?filter=${encodeURIComponent(item.domain)}`}
href=${`/config/logs?search=${encodeURIComponent(item.domain)}`}
>
<ha-dropdown-item value="logs">
<ha-svg-icon
@@ -128,7 +128,7 @@ export const renderConfigEntryError = (
}
return html`
<br />
<a href=${`/config/logs?filter=${encodeURIComponent(entry.domain)}`}>
<a href=${`/config/logs?search=${encodeURIComponent(entry.domain)}`}>
${hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}
@@ -441,7 +441,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
${
this._logInfo
? html`<a
href=${`/config/logs?filter=${encodeURIComponent(this.domain)}`}
href=${`/config/logs?search=${encodeURIComponent(this.domain)}`}
>
<ha-dropdown-item>
<ha-svg-icon
@@ -628,7 +628,7 @@ class ZWaveJSConfigDashboard extends LitElement {
} else {
stateTextExtra = html`
<br />
<a href="/config/logs?filter=zwave_js"
<a href="/config/logs?search=zwave_js"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}</a
+111 -30
View File
@@ -13,10 +13,20 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { atLeastVersion } from "../../../common/config/version";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { currentPath } from "../../../common/url/current-path";
import {
DEFAULT_LOG_PROVIDER,
logProviderFromUrl,
logRawFromUrl,
logSearchFromUrl,
logsQueryParamConfig,
logsQueryParamsFromState,
} from "../../../common/url/logs-query-params";
import { createQueryString } from "../../../common/url/query-params";
import "../../../components/ha-app-icon";
import "../../../components/ha-button";
import "../../../components/ha-generic-picker";
@@ -80,7 +90,7 @@ export class HaConfigLogs extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _filter = extractSearchParam("filter") || "";
@state() private _filter = "";
@state() private _detail = false;
@@ -88,10 +98,18 @@ export class HaConfigLogs extends LitElement {
@query("ha-generic-picker") private providerPicker?: HaGenericPicker;
@state() private _selectedLogProvider = "core";
@state() private _selectedLogProvider = DEFAULT_LOG_PROVIDER;
@state() private _logProviders = logProviders;
// The provider in the URL is only checked once the apps are loaded
private _providersLoaded = false;
private _queryParams = new QueryParamsController(this, logsQueryParamConfig, {
enabled: () => true,
navigated: () => this._applyUrl(),
});
public connectedCallback() {
super.connectedCallback();
const systemLog = this.systemLog;
@@ -100,13 +118,26 @@ export class HaConfigLogs extends LitElement {
}
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._applyUrl();
}
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._init();
}
private async _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
private _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
this._writeUrl(true);
}
private _flushUrl() {
this._queryParams.flush();
}
protected render(): TemplateResult {
@@ -115,6 +146,7 @@ export class HaConfigLogs extends LitElement {
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
@blur=${this._flushUrl}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
@@ -195,6 +227,7 @@ export class HaConfigLogs extends LitElement {
private _showDetail() {
this._detail = !this._detail;
this._writeUrl();
}
private _openPicker(ev: Event) {
@@ -207,39 +240,87 @@ export class HaConfigLogs extends LitElement {
if (!provider) {
return;
}
this._selectedLogProvider = provider;
this._filter = "";
navigate(`/config/logs?provider=${this._selectedLogProvider}`);
// Keep the search typed so far on the entry Back returns to
this._queryParams.flush();
// A new provider is a new history entry, without the search
const queryString = createQueryString(
logsQueryParamsFromState(provider, ""),
logsQueryParamConfig
);
// From the current path, so a trailing slash still matches the controller
navigate(`${currentPath()}${queryString ? `?${queryString}` : ""}`);
}
private async _init() {
if (isComponentLoaded(this.hass.config, "hassio")) {
await this._getInstalledAddons();
}
const providerKey = extractSearchParam("provider");
if (providerKey) {
if (
isComponentLoaded(this.hass.config, "hassio") &&
this._logProviders.find((p) => p.key === providerKey)
) {
this._selectedLogProvider = providerKey;
} else {
navigate("/config/logs", { replace: true });
showAlertDialog(this, {
title:
this.hass.localize("ui.panel.config.logs.provider_not_found") ||
"Log provider not found",
text: this.hass.localize(
"ui.panel.config.logs.provider_not_available",
{
provider:
this._logProviders.find((p) => p.key === providerKey)?.name ||
providerKey,
}
),
});
}
// Keep a search typed while the apps were loading
this._queryParams.flush();
this._providersLoaded = true;
this._applyUrl();
}
// The URL is the state of the page: no provider is Core, no search is none
// and no raw is Core's condensed view
private _applyUrl() {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
this._filter = logSearchFromUrl(searchParams);
this._detail = logRawFromUrl(searchParams);
if (!this._providersLoaded) {
return;
}
const provider = logProviderFromUrl(searchParams);
const providerFound =
provider === DEFAULT_LOG_PROVIDER ||
(isComponentLoaded(this.hass.config, "hassio") &&
this._logProviders.some((p) => p.key === provider));
this._selectedLogProvider = providerFound ? provider : DEFAULT_LOG_PROVIDER;
// Before the alert adds its history entry, so closing it doesn't go back
// to the missing provider
this._writeUrl();
if (!providerFound) {
showAlertDialog(this, {
title:
this.hass.localize("ui.panel.config.logs.provider_not_found") ||
"Log provider not found",
text: this.hass.localize(
"ui.panel.config.logs.provider_not_available",
{
provider:
this._logProviders.find((p) => p.key === provider)?.name ||
provider,
}
),
});
}
}
private _writeUrl(debounced = false) {
// Until the apps are loaded, keep the provider the URL asks for
const provider = this._providersLoaded
? this._selectedLogProvider
: logProviderFromUrl(this._queryParams.searchParams() ?? "");
this._queryParams.write(
logsQueryParamsFromState(provider, this._filter, this._detail),
debounced
);
}
private async _getInstalledAddons() {
+2
View File
@@ -309,6 +309,8 @@ export const getMyRedirects = (): Redirects => ({
redirect: "/config/logs",
params: {
provider: "string?",
search: "string?",
raw: "string?",
},
},
repairs: {
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
logProviderFromUrl,
logRawFromUrl,
logSearchFromUrl,
logsQueryParamConfig,
logsQueryParamsFromState,
} from "../../../src/common/url/logs-query-params";
import { updateQueryString } from "../../../src/common/url/query-params";
describe("logSearchFromUrl", () => {
it("reads search, then the old filter name", () => {
expect(logSearchFromUrl("?search=zwave_js")).toBe("zwave_js");
expect(logSearchFromUrl("?filter=zwave_js")).toBe("zwave_js");
expect(logSearchFromUrl("?search=new&filter=old")).toBe("new");
expect(logSearchFromUrl("")).toBe("");
});
});
describe("logProviderFromUrl", () => {
it("defaults to Core", () => {
expect(logProviderFromUrl("?provider=supervisor")).toBe("supervisor");
expect(logProviderFromUrl("?search=x")).toBe("core");
});
});
describe("logsQueryParamsFromState", () => {
it("leaves out Core and an empty search, and replaces filter", () => {
expect(
updateQueryString(
"?filter=old&other=1",
logsQueryParamsFromState("core", "old"),
logsQueryParamConfig
)
).toBe("other=1&search=old");
expect(
updateQueryString(
"?provider=dns&search=x",
logsQueryParamsFromState("core", ""),
logsQueryParamConfig
)
).toBe("");
});
it("only writes raw for Core", () => {
expect(
updateQueryString(
"",
logsQueryParamsFromState("core", "", true),
logsQueryParamConfig
)
).toBe("raw=1");
expect(
updateQueryString(
"?raw=1",
logsQueryParamsFromState("supervisor", "", true),
logsQueryParamConfig
)
).toBe("provider=supervisor");
expect(logRawFromUrl("?raw=1")).toBe(true);
expect(logRawFromUrl("?raw=true")).toBe(false);
});
});