Compare commits

...
4 changed files with 236 additions and 93 deletions

No files matched your search

+105 -42
View File
@@ -17,7 +17,7 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
import { configContext, internationalizationContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
@@ -33,6 +33,7 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -114,6 +115,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -124,6 +128,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Kept once loading ends, so the skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -155,9 +166,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState")
changedProperties.has("noUnknownState") ||
changedProperties.has("loading")
) {
this._setUnknownValue();
}
@@ -183,45 +198,59 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
<div class="field" ?inert=${this.loading}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role="progressbar"
aria-label=${ifDefined(
this._i18n?.localize("ui.common.loading")
)}
@transitionend=${this._skeletonTransitionEnd}
></ha-skeleton>`
: nothing
}
</div>
${
this._pickerWrapperOpen || this._opened
@@ -298,9 +327,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _skeletonTransitionEnd() {
if (!this.loading) {
this._skeletonShown = false;
}
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -413,7 +449,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled) {
if (this.disabled || this.loading) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -459,6 +496,32 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
#picker {
position: relative;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field,
.loading-skeleton {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
:host(:not([loading])) .loading-skeleton {
opacity: 0;
pointer-events: none;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -1,3 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -12,11 +13,13 @@ import "../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-select";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../components/ha-select";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import "../../../components/ha-label";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "../../../components/ha-picker-combo-box";
import "../../../components/ha-spinner";
import "../../../components/progress/ha-progress-bar";
@@ -48,8 +51,14 @@ import { repositoryAuthors } from "../tools/authors";
import { handleGitHubRateLimited } from "../tools/connect-github";
import { installBlockedReason } from "../tools/install-blocked-reason";
import { generateFrontendResourceURL } from "../tools/frontend-resource";
import type { ValueChangedEvent } from "../../../types";
import type { MarketplaceInstallDialogParams } from "./show-dialog-marketplace-install";
interface ReleaseComboBoxItem extends PickerComboBoxItem {
installed: boolean;
latest: boolean;
}
@customElement("dialog-marketplace-install")
export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDialogParams>(
LitElement
@@ -416,31 +425,35 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
"ui.panel.marketplace.dialog_install.release_warning"
)}
</p>
${
this._releasesFailed
? html`${this._i18n.localize(
"ui.panel.marketplace.dialog_install.releases_failed"
)}
<ha-button appearance="plain" @click=${this._loadReleases}>
${this._i18n.localize("ui.panel.marketplace.common.retry")}
</ha-button>`
: this._releases === undefined
? this._i18n.localize(
"ui.panel.marketplace.dialog_install.fetching_releases"
)
: this._releases.length === 0
<div class="version-field">
${
this._releasesFailed
? html`<span>
${this._i18n.localize(
"ui.panel.marketplace.dialog_install.releases_failed"
)}
</span>
<ha-button appearance="plain" @click=${this._loadReleases}>
${this._i18n.localize("ui.panel.marketplace.common.retry")}
</ha-button>`
: this._releases?.length === 0
? this._i18n.localize(
"ui.panel.marketplace.dialog_install.no_releases"
)
: html`<ha-select
: html`<ha-generic-picker
.label=${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release"
)}
.value=${this._selectedVersion}
.options=${this._releaseOptions(this._releases)}
@selected=${this._versionChanged}
></ha-select>`
}
.getItems=${this._getReleaseItems}
.rowRenderer=${this._releaseRowRenderer}
.loading=${this._releases === undefined}
hide-clear-icon
no-sort
@value-changed=${this._versionChanged}
></ha-generic-picker>`
}
</div>
</div>`;
}
@@ -633,26 +646,73 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
}
}
private _releaseOptions(releases: MarketplaceRelease[]): HaSelectOption[] {
return releases.map((release) => ({
value: release.tag,
label: release.tag,
secondary: [
release.prerelease
? this._i18n.localize(
"ui.panel.marketplace.dialog_install.pre_release"
)
: undefined,
relativeTime(new Date(release.published_at), this._i18n.locale),
release.name && release.name !== release.tag ? release.name : undefined,
]
.filter(Boolean)
.join(" · "),
}));
}
// GitHub lists the newest first, which is the order to keep
private _releaseItems = memoizeOne(
(
releases: MarketplaceRelease[],
repository: RepositoryInfo,
localize: ContextType<typeof internationalizationContext>["localize"],
locale: ContextType<typeof internationalizationContext>["locale"]
): ReleaseComboBoxItem[] =>
releases.map((release) => ({
id: release.tag,
primary: release.tag,
installed:
repository.installed && release.tag === repository.installed_version,
latest: release.tag === repository.available_version,
secondary: [
release.prerelease
? localize("ui.panel.marketplace.dialog_install.pre_release")
: undefined,
relativeTime(new Date(release.published_at), locale),
release.name && release.name !== release.tag
? release.name
: undefined,
]
.filter(Boolean)
.join(" · "),
}))
);
private _versionChanged(ev: HaSelectSelectEvent<string, true>) {
this._selectedVersion = ev.detail.value;
private _getReleaseItems = () =>
this._releases && this._repository
? this._releaseItems(
this._releases,
this._repository,
this._i18n.localize,
this._i18n.locale
)
: [];
private _releaseRowRenderer: RenderItemFunction<ReleaseComboBoxItem> = (
item
) =>
html`<ha-combo-box-item>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
${
item.installed
? html`<ha-label slot="end" dense>
${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release_installed"
)}
</ha-label>`
: nothing
}
${
item.latest
? html`<ha-label slot="end" dense>
${this._i18n.localize(
"ui.panel.marketplace.dialog_install.release_latest"
)}
</ha-label>`
: nothing
}
</ha-combo-box-item>`;
private _versionChanged(ev: ValueChangedEvent<string | undefined>) {
if (ev.detail.value) {
this._selectedVersion = ev.detail.value;
}
}
static get styles(): CSSResultGroup {
@@ -667,8 +727,16 @@ export class DialogMarketplaceInstall extends DialogMixin<MarketplaceInstallDial
.content {
padding: var(--ha-space-6);
}
.versions ha-select {
display: block;
/* Holds the field's height while the versions load, so nothing below
it moves when the picker replaces the skeleton */
.version-field {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-height: 56px;
}
.version-field ha-generic-picker {
flex: 1;
}
pre {
white-space: pre-line;
+2 -1
View File
@@ -13093,10 +13093,11 @@
"already_installing": "This is already being installed, hold on.",
"install_failed": "Installation failed. Check the Home Assistant logs for details.",
"pre_release": "Pre-release",
"release_installed": "Installed",
"release_latest": "Latest",
"install_intro": "This installs {name} version {version} in Home Assistant.",
"update_intro": "This updates {name} from version {installed} to {version}.",
"reinstall_intro": "This installs version {version} of {name} again.",
"fetching_releases": "Loading versions…",
"no_releases": "There are no other versions to choose from.",
"release": "Version",
"release_warning": "To go back to an earlier version, restoring a backup is the safer way.",
+17 -6
View File
@@ -48,7 +48,16 @@ vi.mock("../../../src/components/ha-dialog", async () => {
vi.mock("../../../src/components/ha-dialog-footer", () =>
stubElement("ha-dialog-footer")
);
vi.mock("../../../src/components/ha-select", () => stubElement("ha-select"));
vi.mock("../../../src/components/ha-combo-box-item", () =>
stubElement("ha-combo-box-item")
);
vi.mock("../../../src/components/ha-generic-picker", () =>
stubElement("ha-generic-picker")
);
vi.mock("../../../src/components/ha-label", () => stubElement("ha-label"));
vi.mock("../../../src/components/ha-picker-combo-box", () => ({
DEFAULT_ROW_RENDERER_CONTENT: () => "",
}));
vi.mock("../../../src/components/ha-spinner", () => stubElement("ha-spinner"));
vi.mock("../../../src/components/progress/ha-progress-bar", () =>
stubElement("ha-progress-bar")
@@ -260,7 +269,7 @@ describe("dialog-marketplace-install", () => {
// Where the files land is not something to decide an installation on
expect(root.textContent).not.toContain("/config/custom_components");
// Choosing another version is a menu entry of its own
expect(root.querySelector("ha-select")).toBeNull();
expect(root.querySelector("ha-generic-picker")).toBeNull();
});
it("reinstalls the installed version, not the newest", async () => {
@@ -431,8 +440,10 @@ describe("dialog-marketplace-install", () => {
await settle(dialog);
expect(
getInternals(dialog.shadowRoot!.querySelector("ha-select")!).options
).toEqual([expect.objectContaining({ value: "v2" })]);
getInternals(
dialog.shadowRoot!.querySelector("ha-generic-picker")!
).getItems()
).toEqual([expect.objectContaining({ id: "v2" })]);
});
it("tells a failed release list once, next to the versions with a retry", async () => {
@@ -514,9 +525,9 @@ describe("dialog-marketplace-install", () => {
await settle(dialog);
dialog
.shadowRoot!.querySelector("ha-select")!
.shadowRoot!.querySelector("ha-generic-picker")!
.dispatchEvent(
new CustomEvent("selected", { detail: { value: "1.0.0" } })
new CustomEvent("value-changed", { detail: { value: "1.0.0" } })
);
await settle(dialog);