mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-02 11:04:56 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc73eac3b4 | ||
|
|
4d90a326d7 | ||
|
|
51ac0e66a1 | ||
|
|
6a77dc2a01 | ||
|
|
fafcc1d305 |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user