Add UI for Schedule Helper (#13375)

This commit is contained in:
Zack Barett 2022-08-12 08:58:08 -05:00 committed by GitHub
parent 9046c0d0bf
commit 38607a6410
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 494 additions and 13 deletions

View File

@ -46,6 +46,7 @@
"@fullcalendar/daygrid": "5.9.0",
"@fullcalendar/interaction": "5.9.0",
"@fullcalendar/list": "5.9.0",
"@fullcalendar/timegrid": "5.9.0",
"@lit-labs/motion": "^1.0.2",
"@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch",
"@material/chips": "14.0.0-canary.261f2db59.0",

View File

@ -1,7 +1,7 @@
import memoizeOne from "memoize-one";
import { FrontendLocaleData } from "../../data/translation";
import { useAmPm } from "./use_am_pm";
import { polyfillsLoaded } from "../translations/localize";
import { useAmPm } from "./use_am_pm";
if (__BUILD__ === "latest" && polyfillsLoaded) {
await polyfillsLoaded;
@ -64,3 +64,16 @@ const formatTimeWeekdayMem = memoizeOne(
}
)
);
// 21:15
export const formatTime24h = (dateObj: Date) =>
formatTime24hMem().format(dateObj);
const formatTime24hMem = memoizeOne(
() =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
hour12: false,
})
);

View File

@ -1,31 +1,32 @@
import { fetchCounter, updateCounter, deleteCounter } from "./counter";
import { deleteCounter, fetchCounter, updateCounter } from "./counter";
import {
deleteInputBoolean,
fetchInputBoolean,
updateInputBoolean,
deleteInputBoolean,
} from "./input_boolean";
import {
deleteInputButton,
fetchInputButton,
updateInputButton,
deleteInputButton,
} from "./input_button";
import {
deleteInputDateTime,
fetchInputDateTime,
updateInputDateTime,
deleteInputDateTime,
} from "./input_datetime";
import {
deleteInputNumber,
fetchInputNumber,
updateInputNumber,
deleteInputNumber,
} from "./input_number";
import {
deleteInputSelect,
fetchInputSelect,
updateInputSelect,
deleteInputSelect,
} from "./input_select";
import { fetchInputText, updateInputText, deleteInputText } from "./input_text";
import { fetchTimer, updateTimer, deleteTimer } from "./timer";
import { deleteInputText, fetchInputText, updateInputText } from "./input_text";
import { deleteSchedule, fetchSchedule, updateSchedule } from "./schedule";
import { deleteTimer, fetchTimer, updateTimer } from "./timer";
export const HELPERS_CRUD = {
input_boolean: {
@ -68,4 +69,9 @@ export const HELPERS_CRUD = {
update: updateTimer,
delete: deleteTimer,
},
schedule: {
fetch: fetchSchedule,
update: updateSchedule,
delete: deleteSchedule,
},
};

61
src/data/schedule.ts Normal file
View File

@ -0,0 +1,61 @@
import { HomeAssistant } from "../types";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
export interface ScheduleDay {
from: string;
to: string;
}
type ScheduleDays = { [K in typeof weekdays[number]]?: ScheduleDay[] };
export interface Schedule extends ScheduleDays {
id: string;
name: string;
icon?: string;
}
export interface ScheduleMutableParams {
name: string;
icon: string;
}
export const fetchSchedule = (hass: HomeAssistant) =>
hass.callWS<Schedule[]>({ type: "schedule/list" });
export const createSchedule = (
hass: HomeAssistant,
values: ScheduleMutableParams
) =>
hass.callWS<Schedule>({
type: "schedule/create",
...values,
});
export const updateSchedule = (
hass: HomeAssistant,
id: string,
updates: Partial<ScheduleMutableParams>
) =>
hass.callWS<Schedule>({
type: "schedule/update",
schedule_id: id,
...updates,
});
export const deleteSchedule = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "schedule/delete",
schedule_id: id,
});
export const getScheduleTime = (date: Date): string =>
`${("0" + date.getHours()).slice(-2)}:${("0" + date.getMinutes()).slice(-2)}`;

View File

@ -8,4 +8,5 @@ export const PLATFORMS_WITH_SETTINGS_TAB = {
counter: "entity-settings-helper-tab",
timer: "entity-settings-helper-tab",
input_button: "entity-settings-helper-tab",
schedule: "entity-settings-helper-tab",
};

View File

@ -6,7 +6,7 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
@ -26,6 +26,7 @@ import "../../../helpers/forms/ha-input_datetime-form";
import "../../../helpers/forms/ha-input_number-form";
import "../../../helpers/forms/ha-input_select-form";
import "../../../helpers/forms/ha-input_text-form";
import "../../../helpers/forms/ha-schedule-form";
import "../../../helpers/forms/ha-timer-form";
import "../../entity-registry-basic-editor";
import type { HaEntityRegistryBasicEditor } from "../../entity-registry-basic-editor";

View File

@ -5,6 +5,7 @@ import type { InputDateTime } from "../../../data/input_datetime";
import type { InputNumber } from "../../../data/input_number";
import type { InputSelect } from "../../../data/input_select";
import type { InputText } from "../../../data/input_text";
import type { Schedule } from "../../../data/schedule";
import type { Timer } from "../../../data/timer";
export const HELPER_DOMAINS = [
@ -16,6 +17,7 @@ export const HELPER_DOMAINS = [
"input_select",
"counter",
"timer",
"schedule",
];
export type Helper =
@ -26,4 +28,5 @@ export type Helper =
| InputSelect
| InputDateTime
| Counter
| Timer;
| Timer
| Schedule;

View File

@ -19,6 +19,7 @@ import { createInputNumber } from "../../../data/input_number";
import { createInputSelect } from "../../../data/input_select";
import { createInputText } from "../../../data/input_text";
import { domainToName } from "../../../data/integration";
import { createSchedule } from "../../../data/schedule";
import { createTimer } from "../../../data/timer";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { haStyleDialog } from "../../../resources/styles";
@ -32,6 +33,7 @@ import "./forms/ha-input_datetime-form";
import "./forms/ha-input_number-form";
import "./forms/ha-input_select-form";
import "./forms/ha-input_text-form";
import "./forms/ha-schedule-form";
import "./forms/ha-timer-form";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
@ -44,6 +46,7 @@ const HELPERS = {
input_select: createInputSelect,
counter: createCounter,
timer: createTimer,
schedule: createSchedule,
};
@customElement("dialog-helper-detail")

View File

@ -0,0 +1,379 @@
// @ts-ignore
import fullcalendarStyle from "@fullcalendar/common/main.css";
import { Calendar, CalendarOptions } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
// @ts-ignore
import timegridStyle from "@fullcalendar/timegrid/main.css";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatTime24h } from "../../../../common/datetime/format_time";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-textfield";
import { Schedule, ScheduleDay, weekdays } from "../../../../data/schedule";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
const defaultFullCalendarConfig: CalendarOptions = {
plugins: [timeGridPlugin, interactionPlugin],
headerToolbar: false,
initialView: "timeGridWeek",
editable: true,
selectable: true,
selectMirror: true,
selectOverlap: false,
eventOverlap: false,
allDaySlot: false,
height: "parent",
locales: allLocales,
firstDay: 1,
dayHeaderFormat: { weekday: "short", month: undefined, day: undefined },
slotLabelFormat: { hour: "numeric", minute: undefined, meridiem: "narrow" },
};
@customElement("ha-schedule-form")
class HaScheduleForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public new?: boolean;
@state() private _name!: string;
@state() private _icon!: string;
@state() private _monday!: ScheduleDay[];
@state() private _tuesday!: ScheduleDay[];
@state() private _wednesday!: ScheduleDay[];
@state() private _thursday!: ScheduleDay[];
@state() private _friday!: ScheduleDay[];
@state() private _saturday!: ScheduleDay[];
@state() private _sunday!: ScheduleDay[];
@state() private calendar?: Calendar;
private _item?: Schedule;
set item(item: Schedule) {
this._item = item;
if (item) {
this._name = item.name || "";
this._icon = item.icon || "";
this._monday = item.monday || [];
this._tuesday = item.tuesday || [];
this._wednesday = item.wednesday || [];
this._thursday = item.thursday || [];
this._friday = item.friday || [];
this._saturday = item.saturday || [];
this._sunday = item.sunday || [];
} else {
this._name = "";
this._icon = "";
this._monday = [];
this._tuesday = [];
this._wednesday = [];
this._thursday = [];
this._friday = [];
this._saturday = [];
this._sunday = [];
}
}
public focus() {
this.updateComplete.then(() =>
(
this.shadowRoot?.querySelector("[dialogInitialFocus]") as HTMLElement
)?.focus()
);
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
}
const nameInvalid = !this._name || this._name.trim() === "";
return html`
<div class="form">
<ha-textfield
.value=${this._name}
.configValue=${"name"}
@input=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
.errorMessage=${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}
.invalid=${nameInvalid}
dialogInitialFocus
></ha-textfield>
<ha-icon-picker
.value=${this._icon}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
></ha-icon-picker>
<div id="calendar"></div>
</div>
`;
}
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.calendar) {
return;
}
if (
changedProps.has("_sunday") ||
changedProps.has("_monday") ||
changedProps.has("_tuesday") ||
changedProps.has("_wednesday") ||
changedProps.has("_thursday") ||
changedProps.has("_friday") ||
changedProps.has("_saturday") ||
changedProps.has("calendar")
) {
this.calendar.removeAllEventSources();
this.calendar.addEventSource(this._events);
}
const oldHass = changedProps.get("hass") as HomeAssistant;
if (oldHass && oldHass.language !== this.hass.language) {
this.calendar.setOption("locale", this.hass.language);
}
}
protected firstUpdated(): void {
const config: CalendarOptions = {
...defaultFullCalendarConfig,
locale: this.hass.language,
eventTimeFormat: {
hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
minute: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
hour12: useAmPm(this.hass.locale),
},
};
config.eventClick = (info) => this._handleEventClick(info);
config.select = (info) => this._handleSelect(info);
config.eventResize = (info) => this._handleEventResize(info);
config.eventDrop = (info) => this._handleEventDrop(info);
this.calendar = new Calendar(
this.shadowRoot!.getElementById("calendar")!,
config
);
this.calendar!.render();
// Update size after fully rendered to avoid a bad render in the more info
this.updateComplete.then(() =>
window.setTimeout(() => {
this.calendar!.updateSize();
}, 500)
);
}
private get _events() {
const events: any[] = [];
const currentDay = new Date().getDay();
for (const [i, day] of weekdays.entries()) {
if (!this[`_${day}`].length) {
continue;
}
this[`_${day}`].forEach((item: ScheduleDay, index: number) => {
const distance = i - currentDay;
const start = new Date();
start.setDate(start.getDate() + distance);
start.setHours(
parseInt(item.from.slice(0, 2)),
parseInt(item.from.slice(-2))
);
const end = new Date();
end.setDate(end.getDate() + distance);
end.setHours(
parseInt(item.to.slice(0, 2)),
parseInt(item.to.slice(-2))
);
events.push({
id: `${day}-${index}`,
start: start.toISOString(),
end: end.toISOString(),
});
});
}
return events;
}
private _handleSelect(info: { start: Date; end: Date }) {
const { start, end } = info;
if (start.getDay() !== end.getDay()) {
this.calendar!.unselect();
return;
}
const day = weekdays[start.getDay()];
const value = [...this[`_${day}`]];
const newValue = { ...this._item };
value.push({
from: formatTime24h(start),
to: formatTime24h(end),
});
newValue[day] = value;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _handleEventResize(info: any) {
const { id, start, end } = info.event;
if (start.getDay() !== end.getDay()) {
info.revert();
return;
}
const [day, index] = id.split("-");
const value = this[`_${day}`][parseInt(index)];
const newValue = { ...this._item };
newValue[day][index] = {
from: value.from,
to: formatTime24h(end),
};
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _handleEventDrop(info: any) {
const { id, start, end } = info.event;
if (start.getDay() !== end.getDay()) {
info.revert();
return;
}
const [day, index] = id.split("-");
const newDay = weekdays[start.getDay()];
const newValue = { ...this._item };
const event = {
from: formatTime24h(start),
to: formatTime24h(end),
};
if (newDay === day) {
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
const value = [...this[`_${newDay}`]];
value.push(event);
newValue[newDay] = value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _handleEventClick(info: any) {
const [day, index] = info.event.id.split("-");
const value = [...this[`_${day}`]];
const newValue = { ...this._item };
value.splice(parseInt(index), 1);
newValue[day] = value;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _valueChanged(ev: CustomEvent) {
if (!this.new && !this._item) {
return;
}
ev.stopPropagation();
const configValue = (ev.target as any).configValue;
const value = ev.detail?.value || (ev.target as any).value;
if (this[`_${configValue}`] === value) {
return;
}
const newValue = { ...this._item };
if (!value) {
delete newValue[configValue];
} else {
newValue[configValue] = value;
}
fireEvent(this, "value-changed", {
value: newValue,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
${unsafeCSS(fullcalendarStyle)}
${unsafeCSS(timegridStyle)}
.form {
color: var(--primary-text-color);
}
ha-textfield {
display: block;
margin: 8px 0;
}
#calendar {
margin: 8px 0;
height: 450px;
width: 100%;
}
.fc-scroller {
overflow-x: visible !important;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-schedule-form": HaScheduleForm;
}
}

View File

@ -1538,7 +1538,8 @@
"input_button": "Button",
"input_datetime": "Date and/or time",
"counter": "Counter",
"timer": "Timer"
"timer": "Timer",
"schedule": "Schedule"
},
"picker": {
"headers": {

View File

@ -1708,7 +1708,7 @@ __metadata:
languageName: node
linkType: hard
"@fullcalendar/daygrid@npm:5.9.0":
"@fullcalendar/daygrid@npm:5.9.0, @fullcalendar/daygrid@npm:~5.9.0":
version: 5.9.0
resolution: "@fullcalendar/daygrid@npm:5.9.0"
dependencies:
@ -1738,6 +1738,17 @@ __metadata:
languageName: node
linkType: hard
"@fullcalendar/timegrid@npm:5.9.0":
version: 5.9.0
resolution: "@fullcalendar/timegrid@npm:5.9.0"
dependencies:
"@fullcalendar/common": ~5.9.0
"@fullcalendar/daygrid": ~5.9.0
tslib: ^2.1.0
checksum: dedef1e1147cd17aa277b159c806e0f927715d67c513d940bec61cb97bfdf97c71b43c03166d8442e9683e2d7d6f03d81619a694de84e04e5995b9e8ef3585b9
languageName: node
linkType: hard
"@gfx/zopfli@npm:^1.0.9":
version: 1.0.11
resolution: "@gfx/zopfli@npm:1.0.11"
@ -9030,6 +9041,7 @@ fsevents@^1.2.7:
"@fullcalendar/daygrid": 5.9.0
"@fullcalendar/interaction": 5.9.0
"@fullcalendar/list": 5.9.0
"@fullcalendar/timegrid": 5.9.0
"@koa/cors": ^3.1.0
"@lit-labs/motion": ^1.0.2
"@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch"