mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-12 06:42:46 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f45689676 | ||
|
|
6ccc78114c |
@@ -319,6 +319,7 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export const durationValueToData = (
|
||||
value?: HaDurationData | string | number
|
||||
): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
offset: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
|
||||
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
offset: () => ({ type: "none" }),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
);
|
||||
private _data = memoizeOne(durationValueToData);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
mdiClockMinusOutline,
|
||||
mdiClockOutline,
|
||||
mdiClockPlusOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type {
|
||||
OffsetSelector,
|
||||
OffsetSelectorValue,
|
||||
OffsetType,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
|
||||
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
|
||||
{ value: "none", iconPath: mdiClockOutline },
|
||||
{ value: "before", iconPath: mdiClockMinusOutline },
|
||||
{ value: "after", iconPath: mdiClockPlusOutline },
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
@customElement("ha-selector-offset")
|
||||
export class HaOffsetSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: OffsetSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: OffsetSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._durationInput?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private get _value(): OffsetSelectorValue | undefined {
|
||||
return isOffsetValue(this.value) ? this.value : undefined;
|
||||
}
|
||||
|
||||
private _duration = memoizeOne(durationValueToData);
|
||||
|
||||
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
|
||||
OFFSET_TYPES.map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(`ui.components.selectors.offset.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = this._value?.type ?? "none";
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
this.label
|
||||
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
|
||||
: nothing
|
||||
}
|
||||
<div class="inputs">
|
||||
<ha-select
|
||||
.value=${type}
|
||||
.options=${this._typeOptions(this.hass.localize)}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._typeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
type !== "none"
|
||||
? html`<div class="value-row">
|
||||
<span class="value-label"
|
||||
>${this.hass.localize(
|
||||
"ui.components.selectors.offset.duration"
|
||||
)}${this.required ? "*" : ""}</span
|
||||
>
|
||||
<ha-duration-input
|
||||
.data=${this._duration(this._value?.duration)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.enableDay=${this.selector.offset?.enable_day}
|
||||
.enableMillisecond=${
|
||||
this.selector.offset?.enable_millisecond
|
||||
}
|
||||
@value-changed=${this._durationChanged}
|
||||
></ha-duration-input>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
|
||||
ev.stopPropagation();
|
||||
const type = this._value?.type;
|
||||
if (!type || type === "none") {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
|
||||
});
|
||||
}
|
||||
|
||||
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.detail.value as OffsetType | undefined;
|
||||
if (!type || type === this._value?.type) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value:
|
||||
type === "none"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
duration:
|
||||
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.inputs,
|
||||
.value-row {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-offset": HaOffsetSelector;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
|
||||
number: () => import("./ha-selector-number"),
|
||||
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
|
||||
object: () => import("./ha-selector-object"),
|
||||
offset: () => import("./ha-selector-offset"),
|
||||
period: () => import("./ha-selector-period"),
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "./entity/entity_registry";
|
||||
import type { EntitySources } from "./entity/entity_sources";
|
||||
import type { HaDurationData } from "../components/ha-duration-input";
|
||||
|
||||
export type ThresholdMode = "crossed" | "changed" | "is";
|
||||
|
||||
@@ -63,6 +64,7 @@ export type Selector =
|
||||
| NumberSelector
|
||||
| NumericThresholdSelector
|
||||
| ObjectSelector
|
||||
| OffsetSelector
|
||||
| PeriodSelector
|
||||
| AssistPipelineSelector
|
||||
| QRCodeSelector
|
||||
@@ -446,6 +448,20 @@ export interface ObjectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type OffsetType = "none" | "before" | "after";
|
||||
|
||||
export interface OffsetSelector {
|
||||
offset: {
|
||||
enable_day?: boolean;
|
||||
enable_millisecond?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OffsetSelectorValue {
|
||||
type: OffsetType;
|
||||
duration?: HaDurationData | string | number;
|
||||
}
|
||||
|
||||
export type PeriodKey =
|
||||
| "today"
|
||||
| "yesterday"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { formatDurationLong } from "../../common/datetime/format_duration";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
import type { OffsetSelectorValue, Selector } from "../selector";
|
||||
|
||||
export const formatSelectorValue = (
|
||||
hass: HomeAssistant,
|
||||
@@ -123,6 +125,21 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("offset" in selector) {
|
||||
const { type, duration } = value as OffsetSelectorValue;
|
||||
const durationData = durationValueToData(duration);
|
||||
if (type === "none" || !durationData) {
|
||||
return "";
|
||||
}
|
||||
const formattedDuration = formatDurationLong(hass.locale, durationData);
|
||||
if (!formattedDuration) {
|
||||
return "";
|
||||
}
|
||||
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
|
||||
duration: formattedDuration,
|
||||
});
|
||||
}
|
||||
|
||||
return ensureArray(value)
|
||||
.map((v) =>
|
||||
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { PlatformTrigger } from "../automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../automation";
|
||||
import type { OffsetSelectorValue } from "../selector";
|
||||
import type { TriggerDescription } from "../trigger";
|
||||
|
||||
const TRIGGER_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
const isOffsetSelectorValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
const absDuration = (duration: HaDurationData): HaDurationData =>
|
||||
Object.fromEntries(
|
||||
Object.entries(duration).map(([field, amount]) => [
|
||||
field,
|
||||
Math.abs(amount ?? 0),
|
||||
])
|
||||
);
|
||||
|
||||
const migrateLegacyOffsetOptions = (
|
||||
options: Record<string, unknown>,
|
||||
fields: TriggerDescription["fields"]
|
||||
): Record<string, unknown> => {
|
||||
let migrated: Record<string, unknown> | undefined;
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
if (!field.selector || !("offset" in field.selector)) {
|
||||
continue;
|
||||
}
|
||||
const typeKey = `${key}_type`;
|
||||
const value = options[key] as OffsetSelectorValue["duration"] | undefined;
|
||||
const hasLegacyType = typeKey in options;
|
||||
if (
|
||||
!hasLegacyType &&
|
||||
(value === undefined || isOffsetSelectorValue(value))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...options };
|
||||
delete migrated[typeKey];
|
||||
if (isOffsetSelectorValue(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = durationValueToData(value ?? 0);
|
||||
if (!duration || Object.values(duration).some((amount) => isNaN(amount))) {
|
||||
continue;
|
||||
}
|
||||
let seconds = durationDataToSeconds(duration);
|
||||
// The released trigger schema defaulted offset_type to before
|
||||
if (options[typeKey] !== "after") {
|
||||
seconds = -seconds;
|
||||
}
|
||||
migrated[key] =
|
||||
seconds === 0
|
||||
? { type: "none" }
|
||||
: {
|
||||
type: seconds < 0 ? "before" : "after",
|
||||
duration: absDuration(duration),
|
||||
};
|
||||
}
|
||||
|
||||
return migrated ?? options;
|
||||
};
|
||||
|
||||
const moveStrayKeysToOptions = (
|
||||
trigger: PlatformTrigger
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated: PlatformTrigger | undefined;
|
||||
for (const key in trigger) {
|
||||
if (TRIGGER_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...trigger, options: { ...trigger.options } };
|
||||
migrated.options![key] = trigger[key];
|
||||
delete migrated[key];
|
||||
}
|
||||
return migrated;
|
||||
};
|
||||
|
||||
export const migratePlatformTrigger = (
|
||||
trigger: PlatformTrigger,
|
||||
description?: TriggerDescription
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated = moveStrayKeysToOptions(trigger);
|
||||
|
||||
const options = (migrated ?? trigger).options;
|
||||
if (options && description?.fields) {
|
||||
const migratedOptions = migrateLegacyOffsetOptions(
|
||||
options,
|
||||
description.fields
|
||||
);
|
||||
if (migratedOptions !== options) {
|
||||
migrated = { ...(migrated ?? trigger), options: migratedOptions };
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
getTriggerObjectId,
|
||||
type TriggerDescription,
|
||||
} from "../../../../../data/trigger";
|
||||
import { migratePlatformTrigger } from "../../../../../data/trigger/migrate_platform_trigger";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
@@ -28,13 +28,6 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -61,34 +54,21 @@ export class HaPlatformTrigger extends LitElement {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
if (
|
||||
changedProperties.has("trigger") ||
|
||||
changedProperties.has("description")
|
||||
) {
|
||||
const migrated = migratePlatformTrigger(this.trigger, this.description);
|
||||
if (migrated) {
|
||||
fireEvent(this, "value-changed", { value: migrated });
|
||||
this.trigger = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newValue: PlatformTrigger | undefined;
|
||||
|
||||
for (const key in this.trigger) {
|
||||
// Migrate old options to `options`
|
||||
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
if (newValue === undefined) {
|
||||
newValue = {
|
||||
...this.trigger,
|
||||
options: { [key]: this.trigger[key] },
|
||||
};
|
||||
} else {
|
||||
newValue.options![key] = this.trigger[key];
|
||||
}
|
||||
delete newValue[key];
|
||||
}
|
||||
if (newValue !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
this.trigger = newValue;
|
||||
}
|
||||
|
||||
const oldValue = changedProperties.get("trigger") as
|
||||
undefined | this["trigger"];
|
||||
|
||||
|
||||
@@ -660,6 +660,16 @@
|
||||
"outside": "Outside range"
|
||||
}
|
||||
},
|
||||
"offset": {
|
||||
"none": "No offset",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"duration": "Duration",
|
||||
"summary": {
|
||||
"before": "{duration} before",
|
||||
"after": "{duration} after"
|
||||
}
|
||||
},
|
||||
"automation_behavior": {
|
||||
"trigger": {
|
||||
"options": {
|
||||
|
||||
Reference in New Issue
Block a user