From f82323a13b87400f11e06e8d7ce128e26c7f6efb Mon Sep 17 00:00:00 2001 From: kadiryazici Date: Tue, 4 Mar 2025 04:43:57 +0300 Subject: [PATCH 01/11] feat: Add Thermostat Climate Preset Management --- .storybook/seed-fake.js | 24 ++ assets/icons/trash.svg | 5 + package-lock.json | 9 +- package.json | 2 +- src/lib/icons/Trash.tsx | 28 ++ .../DeviceDetails/ThermostatDeviceDetails.tsx | 51 +++- src/lib/seam/thermostats/thermostat-device.ts | 1 + .../use-create-thermostat-climate-preset.ts | 98 +++++++ .../use-delete-thermostat-climate-preset.ts | 78 ++++++ .../use-update-thermostat-climate-preset.ts | 99 +++++++ src/lib/ui/Button.tsx | 2 +- src/lib/ui/IconButton.tsx | 2 +- src/lib/ui/thermostat/ClimateModeMenu.tsx | 21 +- src/lib/ui/thermostat/ClimatePreset.tsx | 246 ++++++++++++++++++ src/lib/ui/thermostat/ClimatePresets.tsx | 143 ++++++++++ src/lib/ui/thermostat/FanModeMenu.tsx | 10 +- src/lib/ui/thermostat/ThermostatCard.tsx | 14 +- src/lib/ui/types.ts | 6 +- src/styles/_buttons.scss | 36 ++- src/styles/_thermostat.scss | 208 +++++++++++---- 20 files changed, 1015 insertions(+), 68 deletions(-) create mode 100644 assets/icons/trash.svg create mode 100644 src/lib/icons/Trash.tsx create mode 100644 src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts create mode 100644 src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts create mode 100644 src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts create mode 100644 src/lib/ui/thermostat/ClimatePreset.tsx create mode 100644 src/lib/ui/thermostat/ClimatePresets.tsx diff --git a/.storybook/seed-fake.js b/.storybook/seed-fake.js index 15e258c37..165e0bca0 100644 --- a/.storybook/seed-fake.js +++ b/.storybook/seed-fake.js @@ -412,6 +412,30 @@ export const seedFake = (db) => { image_url: 'https://connect.getseam.com/assets/images/devices/ecobee_3-lite_front.png', image_alt_text: 'Placeholder Lock Image', + available_climate_presets: [ + { + climate_preset_key: 'occupied', + name: 'Occupied', + display_name: 'Occupied', + fan_mode_setting: 'auto', + hvac_mode_setting: 'heat_cool', + cooling_set_point_celsius: 25, + heating_set_point_celsius: 20, + cooling_set_point_fahrenheit: 77, + heating_set_point_fahrenheit: 68, + }, + { + climate_preset_key: 'unoccupied', + name: 'Unoccupied', + display_name: 'Unoccupied', + fan_mode_setting: 'auto', + hvac_mode_setting: 'heat_cool', + cooling_set_point_celsius: 30, + heating_set_point_celsius: 15, + cooling_set_point_fahrenheit: 86, + heating_set_point_fahrenheit: 59, + }, + ], }, errors: [], }) diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg new file mode 100644 index 000000000..59561a578 --- /dev/null +++ b/assets/icons/trash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 2a8b9a2f3..f4f948de4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@rollup/plugin-replace": "^5.0.5", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.1", - "@seamapi/fake-seam-connect": "^1.69.1", + "@seamapi/fake-seam-connect": "^1.76.0", "@seamapi/types": "^1.344.3", "@storybook/addon-designs": "^7.0.1", "@storybook/addon-essentials": "^7.0.2", @@ -5814,11 +5814,10 @@ } }, "node_modules/@seamapi/fake-seam-connect": { - "version": "1.72.1", - "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.72.1.tgz", - "integrity": "sha512-ydDnKE+iOVRU0xEBpTYwpNjIRgV/pVkmPvXNRG76JBYtYf2whZxAPhcMUEjQbRk6tW1r8ykY051aLsWoKfjNPQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.76.0.tgz", + "integrity": "sha512-6+Ie7nFtR+7ZW0MJxWkn9mm2T2Vv0HQAU7VHf/6oRS7reJ1NqhwfWch1VEA/axd+r4VlCO4Fc2rPu3bRUBu5SQ==", "dev": true, - "license": "MIT", "bin": { "fake-seam-connect": "dist/server.js" }, diff --git a/package.json b/package.json index 4919d7061..85c354031 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "@rollup/plugin-replace": "^5.0.5", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.1", - "@seamapi/fake-seam-connect": "^1.69.1", + "@seamapi/fake-seam-connect": "^1.76.0", "@seamapi/types": "^1.344.3", "@storybook/addon-designs": "^7.0.1", "@storybook/addon-essentials": "^7.0.2", diff --git a/src/lib/icons/Trash.tsx b/src/lib/icons/Trash.tsx new file mode 100644 index 000000000..147271e88 --- /dev/null +++ b/src/lib/icons/Trash.tsx @@ -0,0 +1,28 @@ +/* + * Automatically generated by SVGR from assets/icons/*.svg. + * Do not edit this file or add other components to this directory. + */ +import type { SVGProps } from 'react' +export function TrashIcon(props: SVGProps): JSX.Element { + return ( + + + + + ) +} diff --git a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx index 3379bcd1e..b0544e9d3 100644 --- a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx @@ -14,6 +14,7 @@ import { useHeatCoolThermostat } from 'lib/seam/thermostats/use-heat-cool-thermo import { useHeatThermostat } from 'lib/seam/thermostats/use-heat-thermostat.js' import { useSetThermostatFanMode } from 'lib/seam/thermostats/use-set-thermostat-fan-mode.js' import { useSetThermostatOff } from 'lib/seam/thermostats/use-set-thermostat-off.js' +import { Button } from 'lib/ui/Button.js' import { AccordionRow } from 'lib/ui/layout/AccordionRow.js' import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' import { DetailRow } from 'lib/ui/layout/DetailRow.js' @@ -21,6 +22,7 @@ import { DetailSection } from 'lib/ui/layout/DetailSection.js' import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js' import { Snackbar } from 'lib/ui/Snackbar/Snackbar.js' import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' +import { ClimatePresets } from 'lib/ui/thermostat/ClimatePresets.js' import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js' import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' @@ -40,16 +42,37 @@ export function ThermostatDeviceDetails({ className, onEditName, }: ThermostatDeviceDetailsProps): JSX.Element | null { + const [temperatureUnit, setTemperatureUnit] = useState< + 'fahrenheit' | 'celsius' + >('fahrenheit') + const [climateSettingsVisible, setClimateSettingsVisible] = useState(false) + if (device == null) { return null } + if (climateSettingsVisible) { + return ( + { + setClimateSettingsVisible(false) + }} + /> + ) + } + return (
- +
@@ -58,6 +81,12 @@ export function ThermostatDeviceDetails({ tooltipContent={t.currentSettingsTooltip} > + { + setClimateSettingsVisible(true) + }} + device={device} + /> @@ -299,12 +328,32 @@ function ClimateSettingRow({ ) } +interface ClimatePresetRowProps { + device: ThermostatDevice + onClickManage: () => void +} + +function ClimatePresetRow({ + device, + onClickManage, +}: ClimatePresetRowProps): JSX.Element { + return ( + + + + ) +} + const t = { thermostat: 'Thermostat', currentSettings: 'Current settings', currentSettingsTooltip: 'These are the settings currently on the device. If you change them here, they change on the device.', climate: 'Climate', + climatePresets: 'Climate presets', fanMode: 'Fan mode', none: 'None', fanModeSuccess: 'Successfully updated fan mode!', diff --git a/src/lib/seam/thermostats/thermostat-device.ts b/src/lib/seam/thermostats/thermostat-device.ts index 000cee4bf..b65b25af7 100644 --- a/src/lib/seam/thermostats/thermostat-device.ts +++ b/src/lib/seam/thermostats/thermostat-device.ts @@ -13,6 +13,7 @@ export type ThermostatDevice = Omit & { | 'available_hvac_mode_settings' | 'fan_mode_setting' | 'current_climate_setting' + | 'available_climate_presets' > > } diff --git a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts new file mode 100644 index 000000000..69688fe98 --- /dev/null +++ b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts @@ -0,0 +1,98 @@ +import type { + SeamHttpApiError, + ThermostatsCreateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseCreateThermostatClimatePresetParams = never +export type UseCreateThermostatClimatePresetData = undefined + +export type UseCreateThermostatClimatePresetVariables = ThermostatsCreateClimatePresetBody + +const fhToCelsius = (t?: number): number | undefined => t == null ? undefined : (t - 32) * (5 / 9) + +type ClimatePreset = ThermostatDevice['properties']['available_climate_presets'][number]; + +export function useCreateThermostatClimatePreset(): UseMutationResult< + UseCreateThermostatClimatePresetData, + SeamHttpApiError, + UseCreateThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseCreateThermostatClimatePresetData, + SeamHttpApiError, + UseCreateThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.createClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + const preset: ClimatePreset = { + ...variables, + cooling_set_point_celsius: fhToCelsius(variables.cooling_set_point_fahrenheit), + heating_set_point_celsius: fhToCelsius(variables.heating_set_point_fahrenheit), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: true, + }; + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return; + } + + return getUpdatedDevice(device, preset) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, preset) + } + + return device + }) + } + ) + }, + }) +} + + +function getUpdatedDevice(device: ThermostatDevice, preset: ClimatePreset): ThermostatDevice { + if (device == null) { + return device; + } + + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: [ + preset, + ...(device.properties.available_climate_presets ?? []), + ], + }, + } +} diff --git a/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts new file mode 100644 index 000000000..a48f4f0ff --- /dev/null +++ b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts @@ -0,0 +1,78 @@ +import type { + SeamHttpApiError, + ThermostatsDeleteClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseDeleteThermostatClimatePresetParams = never + +export type UseDeleteThermostatClimatePresetData = undefined + +export type UseDeleteThermostatClimatePresetVariables = ThermostatsDeleteClimatePresetBody + +export function useDeleteThermostatClimatePreset(): UseMutationResult< + UseDeleteThermostatClimatePresetData, + SeamHttpApiError, + UseDeleteThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseDeleteThermostatClimatePresetData, + SeamHttpApiError, + UseDeleteThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.deleteClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables.climate_preset_key) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, variables.climate_preset_key) + } + + return device + }) + } + ) + }, + }) +} + + +function getUpdatedDevice(device: ThermostatDevice, climatePresetKey: string): ThermostatDevice { + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: device.properties.available_climate_presets.filter(preset => preset.climate_preset_key !== climatePresetKey), + } + } +} \ No newline at end of file diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts new file mode 100644 index 000000000..fe5dcdf07 --- /dev/null +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -0,0 +1,99 @@ +import type { + SeamHttpApiError, + ThermostatsUpdateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseUpdateThermostatClimatePresetParams = never +export type UseUpdateThermostatClimatePresetData = undefined + +export type UseUpdateThermostatClimatePresetVariables = ThermostatsUpdateClimatePresetBody + +const fhToCelsius = (t?: number): number | undefined => t == null ? undefined : (t - 32) * (5 / 9) + +type ClimatePreset = ThermostatDevice['properties']['available_climate_presets'][number]; + +export function useUpdateThermostatClimatePreset({ originalKey }: { originalKey: ClimatePreset['climate_preset_key'] }): UseMutationResult< + UseUpdateThermostatClimatePresetData, + SeamHttpApiError, + UseUpdateThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseUpdateThermostatClimatePresetData, + SeamHttpApiError, + UseUpdateThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.createClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + const preset: ClimatePreset = { + ...variables, + cooling_set_point_celsius: fhToCelsius(variables.cooling_set_point_fahrenheit), + heating_set_point_celsius: fhToCelsius(variables.heating_set_point_fahrenheit), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: true, + }; + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return; + } + + return getUpdatedDevice(device, originalKey, preset) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, originalKey, preset) + } + + return device + }) + } + ) + }, + }) +} + + +function getUpdatedDevice(device: ThermostatDevice, originalKey: ClimatePreset['climate_preset_key'], preset: ClimatePreset): ThermostatDevice { + if (device == null) { + return device; + } + + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: [ + preset, + ...(device.properties.available_climate_presets ?? []) + .filter(preset => preset.climate_preset_key !== originalKey), + ], + }, + } +} diff --git a/src/lib/ui/Button.tsx b/src/lib/ui/Button.tsx index c074c2b38..9842ef0c8 100644 --- a/src/lib/ui/Button.tsx +++ b/src/lib/ui/Button.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames' import type { MouseEventHandler, PropsWithChildren } from 'react' interface ButtonProps extends PropsWithChildren { - variant?: 'solid' | 'outline' | 'neutral' + variant?: 'solid' | 'outline' | 'neutral' | 'danger' size?: 'small' | 'medium' | 'large' type?: 'button' | 'submit' disabled?: boolean diff --git a/src/lib/ui/IconButton.tsx b/src/lib/ui/IconButton.tsx index f8ccb78cf..be87260ff 100644 --- a/src/lib/ui/IconButton.tsx +++ b/src/lib/ui/IconButton.tsx @@ -4,6 +4,6 @@ import type { ButtonProps } from 'lib/ui/types.js' export function IconButton({ className, ...props }: ButtonProps): JSX.Element { return ( - )} diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx new file mode 100644 index 000000000..16bbc6fe2 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -0,0 +1,246 @@ +import classNames from "classnames"; +import { type HTMLAttributes,useMemo, useRef } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import type { HvacModeSetting, ThermostatDevice } from "lib/seam/thermostats/thermostat-device.js"; +import { Button } from "lib/ui/Button.js"; +import { FormField } from "lib/ui/FormField.js"; +import { InputLabel } from "lib/ui/InputLabel.js"; +import { ContentHeader } from "lib/ui/layout/ContentHeader.js"; +import { TextField } from "lib/ui/TextField/TextField.js"; +import { ClimateModeMenu } from "lib/ui/thermostat/ClimateModeMenu.js"; +import { FanModeMenu } from "lib/ui/thermostat/FanModeMenu.js"; +import { TemperatureControlGroup } from "lib/ui/thermostat/TemperatureControlGroup.js"; + +type Preset = + ThermostatDevice['properties']['available_climate_presets'][number] + +export type ClimatePresetProps = { + preset?: Preset + onBack: () => void + device: ThermostatDevice +} & Omit, 'children'> + +export function ClimatePreset(props: ClimatePresetProps): JSX.Element { + const { preset, onBack, device, ...attrs } = props + + const originalPreset = useRef(preset ?? { + climate_preset_key: "", + can_edit: true, + can_delete: true, + display_name: "", + fan_mode_setting: "auto", + hvac_mode_setting: "off", + cooling_set_point_celsius: undefined, + heating_set_point_celsius: undefined, + cooling_set_point_fahrenheit: undefined, + heating_set_point_fahrenheit: undefined, + manual_override_allowed: true, + name: "", + }) + + const { + register, + handleSubmit, + formState: { errors }, + watch, + setValue, + control, + setError, + } = useForm({ + defaultValues: { + key: originalPreset.current.climate_preset_key, + name: originalPreset.current.display_name, + hvacMode: originalPreset.current.hvac_mode_setting, + heatPoint: originalPreset.current.heating_set_point_fahrenheit, + coolPoint: originalPreset.current.cooling_set_point_fahrenheit, + fanMode: originalPreset.current.fan_mode_setting, + }, + }) + + const state = watch(); + + const onHvacModeChange = (mode: HvacModeSetting): void => { + if (mode === "heat_cool") { + setValue("heatPoint", originalPreset.current.heating_set_point_fahrenheit); + setValue("coolPoint", originalPreset.current.cooling_set_point_fahrenheit); + } else if (mode === "heat") { + setValue("heatPoint", originalPreset.current.heating_set_point_fahrenheit); + setValue("coolPoint", undefined); + } else if (mode === "cool") { + setValue("heatPoint", undefined); + setValue("coolPoint", originalPreset.current.cooling_set_point_fahrenheit); + } else { + setValue("heatPoint", undefined); + setValue("coolPoint", undefined); + } + } + + const allPresets = useMemo(() => ( + device.properties.available_climate_presets ?? [] + ), [device]) + + const otherPresets = useMemo(() => ( + allPresets.filter(other => other.climate_preset_key !== originalPreset.current.climate_preset_key) + ), [allPresets, preset]) + + const onSubmit = (): void => { + if(otherPresets.some(other => other.climate_preset_key === state.key)) { + setError("key", { type: "validate", message: "Preset with this key already exists." }) + return; + } + + console.log(state); + } + + return ( +
+ + +
+
{ + void handleSubmit(onSubmit)(e) + }} > + + Key + other.climate_preset_key === fixedValue); + } + }), + }} + /> + + + + Display Name (Optional) + + + + + Fan Mode + ( + + )} + /> + + + + HVAC Mode + ( + { + onChange(value); + onHvacModeChange(value); + }} + /> + )} + /> + + + { + state.hvacMode !== "off" && ( + + Heat / Cool + { + setValue("heatPoint", value); + }} + onCoolValueChange={(value) => { + setValue("coolPoint", value); + }} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + heatValue={state.heatPoint!} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + coolValue={state.coolPoint!} + minHeat={65} + maxHeat={100} + minCool={50} + maxCool={90} + delta={5} + /> + + ) + } + +
+ + + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx new file mode 100644 index 000000000..ec29b28d6 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -0,0 +1,143 @@ +import classNames from 'classnames' +import { type HTMLAttributes, useState } from 'react' + +import { AddIcon } from 'lib/icons/Add.js' +import { EditIcon } from 'lib/icons/Edit.js' +import { TrashIcon } from 'lib/icons/Trash.js' +import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { Button } from 'lib/ui/Button.js' +import { IconButton } from 'lib/ui/IconButton.js' +import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js' + +export interface ClimatePresetsManagement { + device: ThermostatDevice + onBack: () => void + temperatureUnit: 'fahrenheit' | 'celsius' +} + +type Preset = + ThermostatDevice['properties']['available_climate_presets'][number] + +export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { + const { device, onBack } = props + + const [selectedClimatePreset, setSelectedClimatePreset] = + useState(null) + + + if (selectedClimatePreset != null) { + return { + setSelectedClimatePreset(null) + }} + device={device} + preset={selectedClimatePreset} + /> + } + + return ( +
+ +
+ + +
+ {device.properties.available_climate_presets.map((preset) => ( + { + setSelectedClimatePreset(preset) + }} + onClickDelete={() => { + console.log('delete') + }} + temperatureUnit={props.temperatureUnit} + preset={preset} + key={preset.climate_preset_key} + /> + ))} +
+
+
+ ) +} + +function PresetCard( + props: HTMLAttributes & { + preset: Preset + temperatureUnit: 'fahrenheit' | 'celsius' + onClickEdit: () => void + onClickDelete: () => void + } +): JSX.Element { + const { preset, temperatureUnit, onClickEdit, onClickDelete, ...attrs } = props + + const heatPoint = + temperatureUnit === 'fahrenheit' + ? preset.heating_set_point_fahrenheit + : (preset.heating_set_point_celsius ?? undefined) + + const coolPoint = + temperatureUnit === 'fahrenheit' + ? preset.cooling_set_point_fahrenheit + : (preset.cooling_set_point_celsius ?? undefined) + + const unitSymbol = temperatureUnit === 'fahrenheit' ? '˚F' : '˚C' + + const chips = ([ + heatPoint != null ? { name: 'Heat', value: `${heatPoint} ${unitSymbol}` } : undefined, + coolPoint != null ? { name: 'Cool', value: `${coolPoint} ${unitSymbol}` } : undefined, + preset.hvac_mode_setting != null ? { name: 'HVAC', value: preset.hvac_mode_setting } : undefined, + preset.fan_mode_setting != null ? { name: 'Fan', value: preset.fan_mode_setting } : undefined, + ] + .filter(Boolean) as Array<{ name: string, value: string }>) + .map(({ name, value }, index) => ( +
+ + {name} + + + {value} + +
+ )) + + return ( +
+
+
+ { preset.display_name } + + {preset.name != null && +
+ {preset.climate_preset_key} +
+ } +
+ +
+ + + + + + + +
+
+ +
+ {chips} +
+
+ ) +} diff --git a/src/lib/ui/thermostat/FanModeMenu.tsx b/src/lib/ui/thermostat/FanModeMenu.tsx index e8c3b2780..1db02c665 100644 --- a/src/lib/ui/thermostat/FanModeMenu.tsx +++ b/src/lib/ui/thermostat/FanModeMenu.tsx @@ -1,3 +1,5 @@ +import classNames from 'classnames' + import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' import { FanIcon } from 'lib/icons/Fan.js' import { FanOutlineIcon } from 'lib/icons/FanOutline.js' @@ -10,13 +12,17 @@ const modes: FanModeSetting[] = ['auto', 'on'] interface FanModeMenuProps { mode: FanModeSetting onChange: (mode: FanModeSetting) => void + block?: boolean, + size?: 'regular' | 'large', } -export function FanModeMenu({ mode, onChange }: FanModeMenuProps): JSX.Element { +export function FanModeMenu({ mode, onChange, block, size = 'regular' }: FanModeMenuProps): JSX.Element { return ( ( - diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index 16bbc6fe2..405118ac6 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -1,16 +1,19 @@ -import classNames from "classnames"; -import { type HTMLAttributes,useMemo, useRef } from "react"; -import { Controller, useForm } from "react-hook-form"; - -import type { HvacModeSetting, ThermostatDevice } from "lib/seam/thermostats/thermostat-device.js"; -import { Button } from "lib/ui/Button.js"; -import { FormField } from "lib/ui/FormField.js"; -import { InputLabel } from "lib/ui/InputLabel.js"; -import { ContentHeader } from "lib/ui/layout/ContentHeader.js"; -import { TextField } from "lib/ui/TextField/TextField.js"; -import { ClimateModeMenu } from "lib/ui/thermostat/ClimateModeMenu.js"; -import { FanModeMenu } from "lib/ui/thermostat/FanModeMenu.js"; -import { TemperatureControlGroup } from "lib/ui/thermostat/TemperatureControlGroup.js"; +import classNames from 'classnames' +import { type HTMLAttributes, useMemo, useRef } from 'react' +import { Controller, useForm } from 'react-hook-form' + +import type { + HvacModeSetting, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' +import { Button } from 'lib/ui/Button.js' +import { FormField } from 'lib/ui/FormField.js' +import { InputLabel } from 'lib/ui/InputLabel.js' +import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { TextField } from 'lib/ui/TextField/TextField.js' +import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' +import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' +import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' type Preset = ThermostatDevice['properties']['available_climate_presets'][number] @@ -24,20 +27,22 @@ export type ClimatePresetProps = { export function ClimatePreset(props: ClimatePresetProps): JSX.Element { const { preset, onBack, device, ...attrs } = props - const originalPreset = useRef(preset ?? { - climate_preset_key: "", + const originalPreset = useRef( + preset ?? { + climate_preset_key: '', can_edit: true, can_delete: true, - display_name: "", - fan_mode_setting: "auto", - hvac_mode_setting: "off", + display_name: '', + fan_mode_setting: 'auto', + hvac_mode_setting: 'off', cooling_set_point_celsius: undefined, heating_set_point_celsius: undefined, cooling_set_point_fahrenheit: undefined, heating_set_point_fahrenheit: undefined, manual_override_allowed: true, - name: "", - }) + name: '', + } + ) const { register, @@ -58,55 +63,66 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { }, }) - const state = watch(); + const state = watch() const onHvacModeChange = (mode: HvacModeSetting): void => { - if (mode === "heat_cool") { - setValue("heatPoint", originalPreset.current.heating_set_point_fahrenheit); - setValue("coolPoint", originalPreset.current.cooling_set_point_fahrenheit); - } else if (mode === "heat") { - setValue("heatPoint", originalPreset.current.heating_set_point_fahrenheit); - setValue("coolPoint", undefined); - } else if (mode === "cool") { - setValue("heatPoint", undefined); - setValue("coolPoint", originalPreset.current.cooling_set_point_fahrenheit); + if (mode === 'heat_cool') { + setValue('heatPoint', originalPreset.current.heating_set_point_fahrenheit) + setValue('coolPoint', originalPreset.current.cooling_set_point_fahrenheit) + } else if (mode === 'heat') { + setValue('heatPoint', originalPreset.current.heating_set_point_fahrenheit) + setValue('coolPoint', undefined) + } else if (mode === 'cool') { + setValue('heatPoint', undefined) + setValue('coolPoint', originalPreset.current.cooling_set_point_fahrenheit) } else { - setValue("heatPoint", undefined); - setValue("coolPoint", undefined); + setValue('heatPoint', undefined) + setValue('coolPoint', undefined) } } - const allPresets = useMemo(() => ( - device.properties.available_climate_presets ?? [] - ), [device]) + const allPresets = useMemo( + () => device.properties.available_climate_presets ?? [], + [device] + ) - const otherPresets = useMemo(() => ( - allPresets.filter(other => other.climate_preset_key !== originalPreset.current.climate_preset_key) - ), [allPresets, preset]) + const otherPresets = useMemo( + () => + allPresets.filter( + (other) => + other.climate_preset_key !== originalPreset.current.climate_preset_key + ), + [allPresets, preset] + ) const onSubmit = (): void => { - if(otherPresets.some(other => other.climate_preset_key === state.key)) { - setError("key", { type: "validate", message: "Preset with this key already exists." }) - return; + if (otherPresets.some((other) => other.climate_preset_key === state.key)) { + setError('key', { + type: 'validate', + message: 'Preset with this key already exists.', + }) + return } - console.log(state); + console.log(state) } return (
- + -
-
{ - void handleSubmit(onSubmit)(e) - }} > +
+ { + void handleSubmit(onSubmit)(e) + }} + > Key other.climate_preset_key === fixedValue); - } + const fixedValue = value.replace(/\s+/g, '').trim() + return !otherPresets.some( + (other) => other.climate_preset_key === fixedValue + ) + }, }), }} /> @@ -145,11 +163,11 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { ...register('name', { maxLength: { value: 20, - message: "max 20 chars" + message: 'max 20 chars', }, minLength: { value: 3, - message: "min 3 chars" + message: 'min 3 chars', }, }), }} @@ -160,11 +178,11 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { Fan Mode ( HVAC Mode ( { - onChange(value); - onHvacModeChange(value); + onChange(value) + onHvacModeChange(value) }} /> )} /> - { - state.hvacMode !== "off" && ( - - Heat / Cool - { - setValue("heatPoint", value); - }} - onCoolValueChange={(value) => { - setValue("coolPoint", value); - }} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - heatValue={state.heatPoint!} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - coolValue={state.coolPoint!} - minHeat={65} - maxHeat={100} - minCool={50} - maxCool={90} - delta={5} - /> - - ) - } + {state.hvacMode !== 'off' && ( + + Heat / Cool + { + setValue('heatPoint', value) + }} + onCoolValueChange={(value) => { + setValue('coolPoint', value) + }} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + heatValue={state.heatPoint!} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + coolValue={state.coolPoint!} + minHeat={65} + maxHeat={100} + minCool={50} + maxCool={90} + delta={5} + /> + + )} -
+
+ -
) -} \ No newline at end of file +} diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx index ec29b28d6..0d93d0106 100644 --- a/src/lib/ui/thermostat/ClimatePresets.tsx +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -25,15 +25,16 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { const [selectedClimatePreset, setSelectedClimatePreset] = useState(null) - if (selectedClimatePreset != null) { - return { - setSelectedClimatePreset(null) - }} - device={device} - preset={selectedClimatePreset} - /> + return ( + { + setSelectedClimatePreset(null) + }} + device={device} + preset={selectedClimatePreset} + /> + ) } return ( @@ -73,7 +74,8 @@ function PresetCard( onClickDelete: () => void } ): JSX.Element { - const { preset, temperatureUnit, onClickEdit, onClickDelete, ...attrs } = props + const { preset, temperatureUnit, onClickEdit, onClickDelete, ...attrs } = + props const heatPoint = temperatureUnit === 'fahrenheit' @@ -87,23 +89,27 @@ function PresetCard( const unitSymbol = temperatureUnit === 'fahrenheit' ? '˚F' : '˚C' - const chips = ([ - heatPoint != null ? { name: 'Heat', value: `${heatPoint} ${unitSymbol}` } : undefined, - coolPoint != null ? { name: 'Cool', value: `${coolPoint} ${unitSymbol}` } : undefined, - preset.hvac_mode_setting != null ? { name: 'HVAC', value: preset.hvac_mode_setting } : undefined, - preset.fan_mode_setting != null ? { name: 'Fan', value: preset.fan_mode_setting } : undefined, - ] - .filter(Boolean) as Array<{ name: string, value: string }>) - .map(({ name, value }, index) => ( -
- - {name} - - - {value} - -
- )) + const chips = ( + [ + heatPoint != null + ? { name: 'Heat', value: `${heatPoint} ${unitSymbol}` } + : undefined, + coolPoint != null + ? { name: 'Cool', value: `${coolPoint} ${unitSymbol}` } + : undefined, + preset.hvac_mode_setting != null + ? { name: 'HVAC', value: preset.hvac_mode_setting } + : undefined, + preset.fan_mode_setting != null + ? { name: 'Fan', value: preset.fan_mode_setting } + : undefined, + ].filter(Boolean) as Array<{ name: string; value: string }> + ).map(({ name, value }, index) => ( +
+ {name} + {value} +
+ )) return (
- { preset.display_name } + {preset.display_name} - {preset.name != null && + {preset.name != null && (
{preset.climate_preset_key}
- } + )}
@@ -135,9 +141,7 @@ function PresetCard(
-
- {chips} -
+
{chips}
) } diff --git a/src/lib/ui/thermostat/FanModeMenu.tsx b/src/lib/ui/thermostat/FanModeMenu.tsx index 1db02c665..6c1cc126c 100644 --- a/src/lib/ui/thermostat/FanModeMenu.tsx +++ b/src/lib/ui/thermostat/FanModeMenu.tsx @@ -12,17 +12,29 @@ const modes: FanModeSetting[] = ['auto', 'on'] interface FanModeMenuProps { mode: FanModeSetting onChange: (mode: FanModeSetting) => void - block?: boolean, - size?: 'regular' | 'large', + block?: boolean + size?: 'regular' | 'large' } -export function FanModeMenu({ mode, onChange, block, size = 'regular' }: FanModeMenuProps): JSX.Element { +export function FanModeMenu({ + mode, + onChange, + block, + size = 'regular', +}: FanModeMenuProps): JSX.Element { return ( ( - ) @@ -360,4 +359,5 @@ const t = { fanModeError: 'Error updating fan mode. Please try again.', climateSettingError: 'Error updating climate setting. Please try again.', saved: 'Saved', + manageNPresets: (n: number) => `Manage (${n} Preset${n <= 1 ? '' : 's'})`, } diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts index ee9ca3aa3..13c43c65c 100644 --- a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -23,11 +23,7 @@ const fhToCelsius = (t?: number): number | undefined => type ClimatePreset = ThermostatDevice['properties']['available_climate_presets'][number] -export function useUpdateThermostatClimatePreset({ - originalKey, -}: { - originalKey: ClimatePreset['climate_preset_key'] -}): UseMutationResult< +export function useUpdateThermostatClimatePreset(): UseMutationResult< UseUpdateThermostatClimatePresetData, SeamHttpApiError, UseUpdateThermostatClimatePresetVariables @@ -66,7 +62,7 @@ export function useUpdateThermostatClimatePreset({ return } - return getUpdatedDevice(device, originalKey, preset) + return getUpdatedDevice(device, preset) } ) @@ -79,7 +75,7 @@ export function useUpdateThermostatClimatePreset({ return devices.map((device) => { if (device.device_id === variables.device_id) { - return getUpdatedDevice(device, originalKey, preset) + return getUpdatedDevice(device, preset) } return device @@ -92,7 +88,6 @@ export function useUpdateThermostatClimatePreset({ function getUpdatedDevice( device: ThermostatDevice, - originalKey: ClimatePreset['climate_preset_key'], preset: ClimatePreset ): ThermostatDevice { if (device == null) { @@ -105,9 +100,7 @@ function getUpdatedDevice( ...device.properties, available_climate_presets: [ preset, - ...(device.properties.available_climate_presets ?? []).filter( - (preset) => preset.climate_preset_key !== originalKey - ), + ...(device.properties.available_climate_presets ?? []) ], }, } diff --git a/src/lib/ui/Button.tsx b/src/lib/ui/Button.tsx index 9842ef0c8..8f9bb4c7b 100644 --- a/src/lib/ui/Button.tsx +++ b/src/lib/ui/Button.tsx @@ -1,6 +1,8 @@ import classNames from 'classnames' import type { MouseEventHandler, PropsWithChildren } from 'react' +import { Spinner } from 'lib/ui/Spinner/Spinner.js' + interface ButtonProps extends PropsWithChildren { variant?: 'solid' | 'outline' | 'neutral' | 'danger' size?: 'small' | 'medium' | 'large' @@ -9,6 +11,7 @@ interface ButtonProps extends PropsWithChildren { onClick?: MouseEventHandler className?: string onMouseDown?: MouseEventHandler + loading?: boolean } export function Button({ @@ -20,6 +23,8 @@ export function Button({ className, onMouseDown, type = 'button', + loading = false, + }: ButtonProps): JSX.Element { return ( ) } diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index 405118ac6..8838fa2e6 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -6,6 +6,8 @@ import type { HvacModeSetting, ThermostatDevice, } from 'lib/seam/thermostats/thermostat-device.js' +import { useCreateThermostatClimatePreset } from 'lib/seam/thermostats/use-create-thermostat-climate-preset.js' +import { useUpdateThermostatClimatePreset } from 'lib/seam/thermostats/use-update-thermostat-climate-preset.js' import { Button } from 'lib/ui/Button.js' import { FormField } from 'lib/ui/FormField.js' import { InputLabel } from 'lib/ui/InputLabel.js' @@ -24,25 +26,27 @@ export type ClimatePresetProps = { device: ThermostatDevice } & Omit, 'children'> +const toCelcius = (t: number): number => (t - 32) * (5 / 9) + export function ClimatePreset(props: ClimatePresetProps): JSX.Element { - const { preset, onBack, device, ...attrs } = props + const { preset: _preset, onBack, device, ...attrs } = props - const originalPreset = useRef( - preset ?? { + const preset = useRef( + _preset ?? { climate_preset_key: '', can_edit: true, can_delete: true, display_name: '', fan_mode_setting: 'auto', hvac_mode_setting: 'off', - cooling_set_point_celsius: undefined, - heating_set_point_celsius: undefined, - cooling_set_point_fahrenheit: undefined, - heating_set_point_fahrenheit: undefined, + heating_set_point_fahrenheit: 80, + cooling_set_point_fahrenheit: 60, + heating_set_point_celsius: toCelcius(80), + cooling_set_point_celsius: toCelcius(60), manual_override_allowed: true, name: '', } - ) + ).current; const { register, @@ -54,12 +58,12 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { setError, } = useForm({ defaultValues: { - key: originalPreset.current.climate_preset_key, - name: originalPreset.current.display_name, - hvacMode: originalPreset.current.hvac_mode_setting, - heatPoint: originalPreset.current.heating_set_point_fahrenheit, - coolPoint: originalPreset.current.cooling_set_point_fahrenheit, - fanMode: originalPreset.current.fan_mode_setting, + key: preset.climate_preset_key, + name: preset.display_name, + hvacMode: preset.hvac_mode_setting, + heatPoint: preset.heating_set_point_fahrenheit, + coolPoint: preset.cooling_set_point_fahrenheit, + fanMode: preset.fan_mode_setting, }, }) @@ -67,14 +71,14 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { const onHvacModeChange = (mode: HvacModeSetting): void => { if (mode === 'heat_cool') { - setValue('heatPoint', originalPreset.current.heating_set_point_fahrenheit) - setValue('coolPoint', originalPreset.current.cooling_set_point_fahrenheit) + setValue('heatPoint', preset.heating_set_point_fahrenheit) + setValue('coolPoint', preset.cooling_set_point_fahrenheit) } else if (mode === 'heat') { - setValue('heatPoint', originalPreset.current.heating_set_point_fahrenheit) + setValue('heatPoint', preset.heating_set_point_fahrenheit) setValue('coolPoint', undefined) } else if (mode === 'cool') { setValue('heatPoint', undefined) - setValue('coolPoint', originalPreset.current.cooling_set_point_fahrenheit) + setValue('coolPoint', preset.cooling_set_point_fahrenheit) } else { setValue('heatPoint', undefined) setValue('coolPoint', undefined) @@ -90,13 +94,18 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { () => allPresets.filter( (other) => - other.climate_preset_key !== originalPreset.current.climate_preset_key + other.climate_preset_key !== preset.climate_preset_key ), [allPresets, preset] ) + const isCreate = _preset == null + const createMutation = useCreateThermostatClimatePreset(); + const updateMutation = useUpdateThermostatClimatePreset(); + const loading = isCreate ? createMutation.isPending : updateMutation.isPending; + const onSubmit = (): void => { - if (otherPresets.some((other) => other.climate_preset_key === state.key)) { + if (isCreate && otherPresets.some((other) => other.climate_preset_key === state.key)) { setError('key', { type: 'validate', message: 'Preset with this key already exists.', @@ -104,7 +113,28 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { return } - console.log(state) + const name = state.name.replace(/\s+/g, ' ').trim(); + + const body = { + climate_preset_key: state.key, + device_id: device.device_id, + name: name === '' ? undefined : name, + cooling_set_point_fahrenheit: state.coolPoint, + heating_set_point_fahrenheit: state.heatPoint, + fan_mode_setting: state.fanMode, + cooling_set_point_celsius: typeof state.coolPoint === 'number' ? toCelcius(state.coolPoint) : undefined, + heating_set_point_celsius: typeof state.heatPoint === 'number' ? toCelcius(state.heatPoint) : undefined, + hvac_mode_setting: state.hvacMode, + } + + if (isCreate) { + createMutation.mutate(body) + } else { + updateMutation.mutate({ + ...body, + manual_override_allowed: true, + }) + } } return ( @@ -113,7 +143,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { className={classNames('seam-thermostat-climate-preset', attrs.className)} > @@ -123,7 +153,9 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { void handleSubmit(onSubmit)(e) }} > - + { + isCreate && ( + Key + ) + } Display Name (Optional) @@ -241,7 +275,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { @@ -249,7 +283,8 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx index 0d93d0106..ae17ca762 100644 --- a/src/lib/ui/thermostat/ClimatePresets.tsx +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -1,13 +1,18 @@ import classNames from 'classnames' -import { type HTMLAttributes, useState } from 'react' +import { type HTMLAttributes, type ReactNode, useState } from 'react' import { AddIcon } from 'lib/icons/Add.js' import { EditIcon } from 'lib/icons/Edit.js' +import { FanIcon } from 'lib/icons/Fan.js' +import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' +import { ThermostatHeatIcon } from 'lib/icons/ThermostatHeat.js' import { TrashIcon } from 'lib/icons/Trash.js' import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { useDeleteThermostatClimatePreset } from 'lib/seam/thermostats/use-delete-thermostat-climate-preset.js' import { Button } from 'lib/ui/Button.js' import { IconButton } from 'lib/ui/IconButton.js' import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { Spinner } from 'lib/ui/Spinner/Spinner.js' import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js' export interface ClimatePresetsManagement { @@ -16,6 +21,8 @@ export interface ClimatePresetsManagement { temperatureUnit: 'fahrenheit' | 'celsius' } +const CreateNewPresetSymbol = Symbol('CreateNewPreset') + type Preset = ThermostatDevice['properties']['available_climate_presets'][number] @@ -23,16 +30,19 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { const { device, onBack } = props const [selectedClimatePreset, setSelectedClimatePreset] = - useState(null) + useState(null) + + const [inDeletionPresetKey, setInDeletionPresetKey] = useState(null); + const deleteMutation = useDeleteThermostatClimatePreset(); - if (selectedClimatePreset != null) { + if (selectedClimatePreset != null || selectedClimatePreset === CreateNewPresetSymbol) { return ( { setSelectedClimatePreset(null) }} device={device} - preset={selectedClimatePreset} + preset={selectedClimatePreset === CreateNewPresetSymbol ? undefined : selectedClimatePreset} /> ) } @@ -41,7 +51,9 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element {
- @@ -53,11 +65,17 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { setSelectedClimatePreset(preset) }} onClickDelete={() => { - console.log('delete') + setInDeletionPresetKey(preset.climate_preset_key) + deleteMutation.mutate({ + climate_preset_key: preset.climate_preset_key, + device_id: device.device_id, + }) }} temperatureUnit={props.temperatureUnit} preset={preset} key={preset.climate_preset_key} + deletionLoading={deleteMutation.isPending && inDeletionPresetKey === preset.climate_preset_key} + disabled={deleteMutation.isPending && inDeletionPresetKey !== preset.climate_preset_key} /> ))}
@@ -72,9 +90,11 @@ function PresetCard( temperatureUnit: 'fahrenheit' | 'celsius' onClickEdit: () => void onClickDelete: () => void + deletionLoading?: boolean + disabled?: boolean } ): JSX.Element { - const { preset, temperatureUnit, onClickEdit, onClickDelete, ...attrs } = + const { preset, temperatureUnit, onClickEdit, onClickDelete, deletionLoading = false, disabled = false, ...attrs } = props const heatPoint = @@ -92,21 +112,20 @@ function PresetCard( const chips = ( [ heatPoint != null - ? { name: 'Heat', value: `${heatPoint} ${unitSymbol}` } + ? { icon: , value: `${heatPoint} ${unitSymbol}` } : undefined, coolPoint != null - ? { name: 'Cool', value: `${coolPoint} ${unitSymbol}` } - : undefined, - preset.hvac_mode_setting != null - ? { name: 'HVAC', value: preset.hvac_mode_setting } + ? { icon: , value: `${coolPoint} ${unitSymbol}` } : undefined, preset.fan_mode_setting != null - ? { name: 'Fan', value: preset.fan_mode_setting } + ? { icon: , value: preset.fan_mode_setting } : undefined, - ].filter(Boolean) as Array<{ name: string; value: string }> - ).map(({ name, value }, index) => ( + ].filter(Boolean) as Array<{ icon: ReactNode; value: string }> + ).map(({ icon, value }, index) => (
- {name} + + {icon} + {value}
)) @@ -131,12 +150,14 @@ function PresetCard(
- + - - + + { + deletionLoading ? : + }
diff --git a/src/styles/_buttons.scss b/src/styles/_buttons.scss index dc1a882f3..3f3ebfa4f 100644 --- a/src/styles/_buttons.scss +++ b/src/styles/_buttons.scss @@ -155,6 +155,7 @@ @include button-size; @include button-variant; + position: relative; font-weight: 600; cursor: pointer; @@ -162,6 +163,28 @@ &:disabled { cursor: not-allowed; } + + .seam-btn-loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + --spinner-color: currentColor; + } + + .seam-btn-content { + display: contents; + } + + &.seam-btn-loading { + .seam-btn-content { + visibility: hidden; + } + } } } @@ -169,4 +192,4 @@ @include button; @include icon-button; @include text-button; -} +} \ No newline at end of file diff --git a/src/styles/_spinner.scss b/src/styles/_spinner.scss index 55f6513c9..322a3311e 100644 --- a/src/styles/_spinner.scss +++ b/src/styles/_spinner.scss @@ -17,7 +17,7 @@ .seam-spinner { width: $default-size; height: $default-size; - border: $default-border-width solid colors.$primary; + border: $default-border-width solid var(--spinner-color, colors.$primary); border-top: $default-border-width solid transparent; display: inline-block; border-radius: 50%; @@ -41,4 +41,4 @@ border-width: 3px; } } -} +} \ No newline at end of file diff --git a/src/styles/_thermostat.scss b/src/styles/_thermostat.scss index 6f1bbcc06..83b1a5fd8 100644 --- a/src/styles/_thermostat.scss +++ b/src/styles/_thermostat.scss @@ -37,16 +37,12 @@ } } -@mixin temperature-control-thumb-hover( - $secondary-color: colors.$thermo-blue-faded -) { +@mixin temperature-control-thumb-hover($secondary-color: colors.$thermo-blue-faded) { background-color: $secondary-color; } -@mixin temperature-control-thumb( - $primary-color: colors.$thermo-blue, - $secondary-color: colors.$thermo-blue-faded -) { +@mixin temperature-control-thumb($primary-color: colors.$thermo-blue, + $secondary-color: colors.$thermo-blue-faded) { width: var(--thumb-size); height: var(--thumb-size); border-radius: 1em; @@ -69,15 +65,13 @@ @mixin temperature-control-ltr-track($primary-color: colors.$thermo-blue) { background: - linear-gradient($primary-color, $primary-color) 0 / var(--slider-position) - 100% no-repeat, + linear-gradient($primary-color, $primary-color) 0 / var(--slider-position) 100% no-repeat, colors.$text-gray-3; } @mixin temperature-control-rtl-track($primary-color: colors.$thermo-blue) { background: - linear-gradient($primary-color, $primary-color) 100% / - calc(var(--slider-position-rtl)) 100% no-repeat, + linear-gradient($primary-color, $primary-color) 100% / calc(var(--slider-position-rtl)) 100% no-repeat, colors.$text-gray-3; } @@ -108,14 +102,8 @@ --temperature-max: 100; --temperature-current: 50; --temperature-range: calc(var(--temperature-max) - var(--temperature-min)); - --temperature-ratio: calc( - (var(--temperature-current) - var(--temperature-min)) / - var(--temperature-range) - ); - --slider-position: calc( - 0.5 * var(--thumb-size) + var(--temperature-ratio) * - (100% - var(--thumb-size)) - ); + --temperature-ratio: calc((var(--temperature-current) - var(--temperature-min)) / var(--temperature-range)); + --slider-position: calc(0.5 * var(--thumb-size) + var(--temperature-ratio) * (100% - var(--thumb-size))); --slider-position-rtl: calc(100% - var(--slider-position)); display: flex; @@ -162,24 +150,18 @@ &[data-variant='heat'] { &::-webkit-slider-thumb { - @include temperature-control-thumb( - colors.$thermo-orange, - colors.$thermo-orange-faded - ); + @include temperature-control-thumb(colors.$thermo-orange, + colors.$thermo-orange-faded ); } &::-moz-range-thumb { - @include temperature-control-thumb( - colors.$thermo-orange, - colors.$thermo-orange-faded - ); + @include temperature-control-thumb(colors.$thermo-orange, + colors.$thermo-orange-faded ); } &::-ms-thumb { - @include temperature-control-thumb( - colors.$thermo-orange, - colors.$thermo-orange-faded - ); + @include temperature-control-thumb(colors.$thermo-orange, + colors.$thermo-orange-faded ); } &:focus { @@ -218,24 +200,18 @@ &[data-variant='cool'] { &::-webkit-slider-thumb { - @include temperature-control-thumb( - colors.$thermo-blue, - colors.$thermo-blue-faded - ); + @include temperature-control-thumb(colors.$thermo-blue, + colors.$thermo-blue-faded ); } &::-moz-range-thumb { - @include temperature-control-thumb( - colors.$thermo-blue, - colors.$thermo-blue-faded - ); + @include temperature-control-thumb(colors.$thermo-blue, + colors.$thermo-blue-faded ); } &::-ms-thumb { - @include temperature-control-thumb( - colors.$thermo-blue, - colors.$thermo-blue-faded - ); + @include temperature-control-thumb(colors.$thermo-blue, + colors.$thermo-blue-faded ); } &:focus { @@ -704,7 +680,7 @@ .seam-climate-presets-add-button { margin: 0 auto; - display: inline-flex; + display: inline-flex !important; align-items: center; gap: 6px; @@ -766,19 +742,29 @@ gap: 10px; width: 100%; padding: 8px; + justify-content: space-around; } .seam-thermostat-climate-preset-chip { display: inline-flex; flex-flow: row nowrap; gap: 4px; + align-items: center; justify-content: space-between; width: max-content; - .seam-thermostat-climate-preset-chip-name { + .seam-thermostat-climate-preset-chip-icon { + color: colors.$text-gray-1; + font-weight: 600; + + svg { + position: relative; + top: 2px; + } } .seam-thermostat-climate-preset-chip-value { + font-weight: 600; } } } @@ -808,4 +794,4 @@ @include status; @include climate-presets; @include climate-preset; -} +} \ No newline at end of file From 427e13df97de5b6068b8993bbb4bbe088ad2fef9 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Tue, 4 Mar 2025 03:22:28 +0000 Subject: [PATCH 04/11] ci: Format code --- .../DeviceDetails/ThermostatDeviceDetails.tsx | 4 +- .../use-update-thermostat-climate-preset.ts | 2 +- src/lib/ui/Button.tsx | 19 ++-- src/lib/ui/thermostat/ClimatePreset.tsx | 95 ++++++++++--------- src/lib/ui/thermostat/ClimatePresets.tsx | 74 ++++++++++----- src/styles/_buttons.scss | 5 +- src/styles/_spinner.scss | 2 +- src/styles/_thermostat.scss | 66 +++++++++---- 8 files changed, 161 insertions(+), 106 deletions(-) diff --git a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx index 8cfaed88d..c2c7c126a 100644 --- a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx @@ -340,7 +340,9 @@ function ClimatePresetRow({ return ( ) diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts index 13c43c65c..5794ee808 100644 --- a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -100,7 +100,7 @@ function getUpdatedDevice( ...device.properties, available_climate_presets: [ preset, - ...(device.properties.available_climate_presets ?? []) + ...(device.properties.available_climate_presets ?? []), ], }, } diff --git a/src/lib/ui/Button.tsx b/src/lib/ui/Button.tsx index 8f9bb4c7b..65762fd04 100644 --- a/src/lib/ui/Button.tsx +++ b/src/lib/ui/Button.tsx @@ -24,7 +24,6 @@ export function Button({ onMouseDown, type = 'button', loading = false, - }: ButtonProps): JSX.Element { return ( ) } diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index 8838fa2e6..2223d00f3 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -46,7 +46,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { manual_override_allowed: true, name: '', } - ).current; + ).current const { register, @@ -93,19 +93,21 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { const otherPresets = useMemo( () => allPresets.filter( - (other) => - other.climate_preset_key !== preset.climate_preset_key + (other) => other.climate_preset_key !== preset.climate_preset_key ), [allPresets, preset] ) const isCreate = _preset == null - const createMutation = useCreateThermostatClimatePreset(); - const updateMutation = useUpdateThermostatClimatePreset(); - const loading = isCreate ? createMutation.isPending : updateMutation.isPending; + const createMutation = useCreateThermostatClimatePreset() + const updateMutation = useUpdateThermostatClimatePreset() + const loading = isCreate ? createMutation.isPending : updateMutation.isPending const onSubmit = (): void => { - if (isCreate && otherPresets.some((other) => other.climate_preset_key === state.key)) { + if ( + isCreate && + otherPresets.some((other) => other.climate_preset_key === state.key) + ) { setError('key', { type: 'validate', message: 'Preset with this key already exists.', @@ -113,7 +115,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { return } - const name = state.name.replace(/\s+/g, ' ').trim(); + const name = state.name.replace(/\s+/g, ' ').trim() const body = { climate_preset_key: state.key, @@ -122,8 +124,14 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { cooling_set_point_fahrenheit: state.coolPoint, heating_set_point_fahrenheit: state.heatPoint, fan_mode_setting: state.fanMode, - cooling_set_point_celsius: typeof state.coolPoint === 'number' ? toCelcius(state.coolPoint) : undefined, - heating_set_point_celsius: typeof state.heatPoint === 'number' ? toCelcius(state.heatPoint) : undefined, + cooling_set_point_celsius: + typeof state.coolPoint === 'number' + ? toCelcius(state.coolPoint) + : undefined, + heating_set_point_celsius: + typeof state.heatPoint === 'number' + ? toCelcius(state.heatPoint) + : undefined, hvac_mode_setting: state.hvacMode, } @@ -142,10 +150,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { {...attrs} className={classNames('seam-thermostat-climate-preset', attrs.className)} > - +
- { - isCreate && ( - - Key - other.climate_preset_key === fixedValue - ) - }, - }), - }} - /> - - ) - } + {isCreate && ( + + Key + other.climate_preset_key === fixedValue + ) + }, + }), + }} + /> + + )} Display Name (Optional) diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx index ae17ca762..d9f4d6b94 100644 --- a/src/lib/ui/thermostat/ClimatePresets.tsx +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -29,20 +29,30 @@ type Preset = export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { const { device, onBack } = props - const [selectedClimatePreset, setSelectedClimatePreset] = - useState(null) - - const [inDeletionPresetKey, setInDeletionPresetKey] = useState(null); - const deleteMutation = useDeleteThermostatClimatePreset(); - - if (selectedClimatePreset != null || selectedClimatePreset === CreateNewPresetSymbol) { + const [selectedClimatePreset, setSelectedClimatePreset] = useState< + Preset | typeof CreateNewPresetSymbol | null + >(null) + + const [inDeletionPresetKey, setInDeletionPresetKey] = useState< + Preset['climate_preset_key'] | null + >(null) + const deleteMutation = useDeleteThermostatClimatePreset() + + if ( + selectedClimatePreset != null || + selectedClimatePreset === CreateNewPresetSymbol + ) { return ( { setSelectedClimatePreset(null) }} device={device} - preset={selectedClimatePreset === CreateNewPresetSymbol ? undefined : selectedClimatePreset} + preset={ + selectedClimatePreset === CreateNewPresetSymbol + ? undefined + : selectedClimatePreset + } /> ) } @@ -51,9 +61,12 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element {
- @@ -74,8 +87,14 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { temperatureUnit={props.temperatureUnit} preset={preset} key={preset.climate_preset_key} - deletionLoading={deleteMutation.isPending && inDeletionPresetKey === preset.climate_preset_key} - disabled={deleteMutation.isPending && inDeletionPresetKey !== preset.climate_preset_key} + deletionLoading={ + deleteMutation.isPending && + inDeletionPresetKey === preset.climate_preset_key + } + disabled={ + deleteMutation.isPending && + inDeletionPresetKey !== preset.climate_preset_key + } /> ))}
@@ -94,8 +113,15 @@ function PresetCard( disabled?: boolean } ): JSX.Element { - const { preset, temperatureUnit, onClickEdit, onClickDelete, deletionLoading = false, disabled = false, ...attrs } = - props + const { + preset, + temperatureUnit, + onClickEdit, + onClickDelete, + deletionLoading = false, + disabled = false, + ...attrs + } = props const heatPoint = temperatureUnit === 'fahrenheit' @@ -123,9 +149,7 @@ function PresetCard( ].filter(Boolean) as Array<{ icon: ReactNode; value: string }> ).map(({ icon, value }, index) => (
- - {icon} - + {icon} {value}
)) @@ -150,14 +174,18 @@ function PresetCard(
- + - - { - deletionLoading ? : - } + + {deletionLoading ? : }
diff --git a/src/styles/_buttons.scss b/src/styles/_buttons.scss index 3f3ebfa4f..4e7a0f828 100644 --- a/src/styles/_buttons.scss +++ b/src/styles/_buttons.scss @@ -173,7 +173,8 @@ display: flex; justify-content: center; align-items: center; - --spinner-color: currentColor; + + --spinner-color: currentcolor; } .seam-btn-content { @@ -192,4 +193,4 @@ @include button; @include icon-button; @include text-button; -} \ No newline at end of file +} diff --git a/src/styles/_spinner.scss b/src/styles/_spinner.scss index 322a3311e..f58bdf84d 100644 --- a/src/styles/_spinner.scss +++ b/src/styles/_spinner.scss @@ -41,4 +41,4 @@ border-width: 3px; } } -} \ No newline at end of file +} diff --git a/src/styles/_thermostat.scss b/src/styles/_thermostat.scss index 83b1a5fd8..f252b1559 100644 --- a/src/styles/_thermostat.scss +++ b/src/styles/_thermostat.scss @@ -37,12 +37,16 @@ } } -@mixin temperature-control-thumb-hover($secondary-color: colors.$thermo-blue-faded) { +@mixin temperature-control-thumb-hover( + $secondary-color: colors.$thermo-blue-faded +) { background-color: $secondary-color; } -@mixin temperature-control-thumb($primary-color: colors.$thermo-blue, - $secondary-color: colors.$thermo-blue-faded) { +@mixin temperature-control-thumb( + $primary-color: colors.$thermo-blue, + $secondary-color: colors.$thermo-blue-faded +) { width: var(--thumb-size); height: var(--thumb-size); border-radius: 1em; @@ -65,13 +69,15 @@ @mixin temperature-control-ltr-track($primary-color: colors.$thermo-blue) { background: - linear-gradient($primary-color, $primary-color) 0 / var(--slider-position) 100% no-repeat, + linear-gradient($primary-color, $primary-color) 0 / var(--slider-position) + 100% no-repeat, colors.$text-gray-3; } @mixin temperature-control-rtl-track($primary-color: colors.$thermo-blue) { background: - linear-gradient($primary-color, $primary-color) 100% / calc(var(--slider-position-rtl)) 100% no-repeat, + linear-gradient($primary-color, $primary-color) 100% / + calc(var(--slider-position-rtl)) 100% no-repeat, colors.$text-gray-3; } @@ -102,8 +108,14 @@ --temperature-max: 100; --temperature-current: 50; --temperature-range: calc(var(--temperature-max) - var(--temperature-min)); - --temperature-ratio: calc((var(--temperature-current) - var(--temperature-min)) / var(--temperature-range)); - --slider-position: calc(0.5 * var(--thumb-size) + var(--temperature-ratio) * (100% - var(--thumb-size))); + --temperature-ratio: calc( + (var(--temperature-current) - var(--temperature-min)) / + var(--temperature-range) + ); + --slider-position: calc( + 0.5 * var(--thumb-size) + var(--temperature-ratio) * + (100% - var(--thumb-size)) + ); --slider-position-rtl: calc(100% - var(--slider-position)); display: flex; @@ -150,18 +162,24 @@ &[data-variant='heat'] { &::-webkit-slider-thumb { - @include temperature-control-thumb(colors.$thermo-orange, - colors.$thermo-orange-faded ); + @include temperature-control-thumb( + colors.$thermo-orange, + colors.$thermo-orange-faded + ); } &::-moz-range-thumb { - @include temperature-control-thumb(colors.$thermo-orange, - colors.$thermo-orange-faded ); + @include temperature-control-thumb( + colors.$thermo-orange, + colors.$thermo-orange-faded + ); } &::-ms-thumb { - @include temperature-control-thumb(colors.$thermo-orange, - colors.$thermo-orange-faded ); + @include temperature-control-thumb( + colors.$thermo-orange, + colors.$thermo-orange-faded + ); } &:focus { @@ -200,18 +218,24 @@ &[data-variant='cool'] { &::-webkit-slider-thumb { - @include temperature-control-thumb(colors.$thermo-blue, - colors.$thermo-blue-faded ); + @include temperature-control-thumb( + colors.$thermo-blue, + colors.$thermo-blue-faded + ); } &::-moz-range-thumb { - @include temperature-control-thumb(colors.$thermo-blue, - colors.$thermo-blue-faded ); + @include temperature-control-thumb( + colors.$thermo-blue, + colors.$thermo-blue-faded + ); } &::-ms-thumb { - @include temperature-control-thumb(colors.$thermo-blue, - colors.$thermo-blue-faded ); + @include temperature-control-thumb( + colors.$thermo-blue, + colors.$thermo-blue-faded + ); } &:focus { @@ -690,7 +714,7 @@ svg, path { - fill: currentColor; + fill: currentcolor; } } @@ -794,4 +818,4 @@ @include status; @include climate-presets; @include climate-preset; -} \ No newline at end of file +} From c63fa44a8526faf5266e440701f72841b2a3b02d Mon Sep 17 00:00:00 2001 From: kadiryazici Date: Tue, 4 Mar 2025 06:25:15 +0300 Subject: [PATCH 05/11] Update order --- src/lib/ui/thermostat/ClimatePreset.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index 2223d00f3..d021c606d 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -241,8 +241,8 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion mode={value!} onChange={(value) => { - onChange(value) onHvacModeChange(value) + onChange(value); }} /> )} From 332947119930e071b4e73ee7843a2122bd31745b Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Tue, 4 Mar 2025 03:26:12 +0000 Subject: [PATCH 06/11] ci: Format code --- src/lib/ui/thermostat/ClimatePreset.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index d021c606d..aacf7e193 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -242,7 +242,7 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { mode={value!} onChange={(value) => { onHvacModeChange(value) - onChange(value); + onChange(value) }} /> )} From b0e87a2a357c9d600fea6c8fb2318c164fbcf16f Mon Sep 17 00:00:00 2001 From: kadiryazici Date: Sat, 8 Mar 2025 00:39:26 +0300 Subject: [PATCH 07/11] Refactor climate preset form --- src/lib/seam/thermostats/thermostat-device.ts | 2 + src/lib/seam/thermostats/unit-conversion.ts | 10 +- .../use-create-thermostat-climate-preset.ts | 47 +- .../use-delete-thermostat-climate-preset.ts | 12 +- .../use-update-thermostat-climate-preset.ts | 43 +- src/lib/ui/thermostat/ClimateModeMenu.tsx | 3 +- src/lib/ui/thermostat/ClimatePreset.tsx | 502 ++++++++++-------- src/lib/ui/thermostat/ClimatePresets.tsx | 29 +- 8 files changed, 360 insertions(+), 288 deletions(-) diff --git a/src/lib/seam/thermostats/thermostat-device.ts b/src/lib/seam/thermostats/thermostat-device.ts index b65b25af7..3422892a7 100644 --- a/src/lib/seam/thermostats/thermostat-device.ts +++ b/src/lib/seam/thermostats/thermostat-device.ts @@ -37,3 +37,5 @@ export interface ClimateSetting { export const isThermostatDevice = ( device: Device ): device is ThermostatDevice => 'is_fan_running' in device.properties + +export type ThermostatClimatePreset = ThermostatDevice['properties']['available_climate_presets'][number] diff --git a/src/lib/seam/thermostats/unit-conversion.ts b/src/lib/seam/thermostats/unit-conversion.ts index f09c69a26..73c4a44c2 100644 --- a/src/lib/seam/thermostats/unit-conversion.ts +++ b/src/lib/seam/thermostats/unit-conversion.ts @@ -1,8 +1,10 @@ import type { Device } from '@seamapi/types/connect' -export const celsiusToFahrenheit = (t: number): number => (t * 9) / 5 + 32 +type ConversionReturn = T extends NonNullable ? number : T -export const fahrenheitToCelsius = (t: number): number => (t - 32) * (5 / 9) +export const celsiusToFahrenheit = (t: T): ConversionReturn => (typeof t === 'number' ? ((t * 9) / 5 + 32) : t) as ConversionReturn + +export const fahrenheitToCelsius = (t: T): ConversionReturn => (typeof t === 'number' ? ((t - 32) * (5 / 9)) : t) as ConversionReturn export const getCoolingSetPointCelsius = ( variables: { @@ -87,3 +89,7 @@ export const getHeatingSetPointFahrenheit = ( undefined ) } + +export function getTemperatureUnitSymbol(type: "fahrenheit" | "celsius"): string { + return type === "fahrenheit" ? "°F" : "°C" +} \ No newline at end of file diff --git a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts index 58dd8ea5f..68a72fabb 100644 --- a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts @@ -8,7 +8,8 @@ import { useQueryClient, } from '@tanstack/react-query' -import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' export type UseCreateThermostatClimatePresetParams = never @@ -17,12 +18,6 @@ export type UseCreateThermostatClimatePresetData = undefined export type UseCreateThermostatClimatePresetVariables = ThermostatsCreateClimatePresetBody -const fhToCelsius = (t?: number): number | undefined => - t == null ? undefined : (t - 32) * (5 / 9) - -type ClimatePreset = - ThermostatDevice['properties']['available_climate_presets'][number] - export function useCreateThermostatClimatePreset(): UseMutationResult< UseCreateThermostatClimatePresetData, SeamHttpApiError, @@ -41,20 +36,6 @@ export function useCreateThermostatClimatePreset(): UseMutationResult< await client.thermostats.createClimatePreset(variables) }, onSuccess: (_data, variables) => { - const preset: ClimatePreset = { - ...variables, - cooling_set_point_celsius: fhToCelsius( - variables.cooling_set_point_fahrenheit - ), - heating_set_point_celsius: fhToCelsius( - variables.heating_set_point_fahrenheit - ), - display_name: variables.name ?? variables.climate_preset_key, - can_delete: true, - can_edit: true, - manual_override_allowed: true, - } - queryClient.setQueryData( ['devices', 'get', { device_id: variables.device_id }], (device) => { @@ -62,7 +43,7 @@ export function useCreateThermostatClimatePreset(): UseMutationResult< return } - return getUpdatedDevice(device, preset) + return getUpdatedDevice(device, variables) } ) @@ -75,7 +56,7 @@ export function useCreateThermostatClimatePreset(): UseMutationResult< return devices.map((device) => { if (device.device_id === variables.device_id) { - return getUpdatedDevice(device, preset) + return getUpdatedDevice(device, variables) } return device @@ -86,12 +67,22 @@ export function useCreateThermostatClimatePreset(): UseMutationResult< }) } -function getUpdatedDevice( +const getUpdatedDevice =( device: ThermostatDevice, - preset: ClimatePreset -): ThermostatDevice { - if (device == null) { - return device + variables: UseCreateThermostatClimatePresetVariables +): ThermostatDevice => { + const preset: ThermostatClimatePreset = { + ...variables, + cooling_set_point_celsius: fahrenheitToCelsius( + variables.cooling_set_point_fahrenheit + ), + heating_set_point_celsius: fahrenheitToCelsius( + variables.heating_set_point_fahrenheit + ), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: false, } return { diff --git a/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts index 6a094484b..9181d7f95 100644 --- a/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts @@ -43,7 +43,7 @@ export function useDeleteThermostatClimatePreset(): UseMutationResult< return } - return getUpdatedDevice(device, variables.climate_preset_key) + return getUpdatedDevice(device, variables) } ) @@ -56,7 +56,7 @@ export function useDeleteThermostatClimatePreset(): UseMutationResult< return devices.map((device) => { if (device.device_id === variables.device_id) { - return getUpdatedDevice(device, variables.climate_preset_key) + return getUpdatedDevice(device, variables) } return device @@ -69,16 +69,16 @@ export function useDeleteThermostatClimatePreset(): UseMutationResult< function getUpdatedDevice( device: ThermostatDevice, - climatePresetKey: string + variables: UseDeleteThermostatClimatePresetVariables ): ThermostatDevice { return { ...device, properties: { ...device.properties, available_climate_presets: - device.properties.available_climate_presets.filter( - (preset) => preset.climate_preset_key !== climatePresetKey - ), + device.properties.available_climate_presets.filter((preset) => { + return preset.climate_preset_key !== variables.climate_preset_key + }), }, } } diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts index 5794ee808..1f8b9dd99 100644 --- a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -8,7 +8,8 @@ import { useQueryClient, } from '@tanstack/react-query' -import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' export type UseUpdateThermostatClimatePresetParams = never @@ -17,12 +18,6 @@ export type UseUpdateThermostatClimatePresetData = undefined export type UseUpdateThermostatClimatePresetVariables = ThermostatsUpdateClimatePresetBody -const fhToCelsius = (t?: number): number | undefined => - t == null ? undefined : (t - 32) * (5 / 9) - -type ClimatePreset = - ThermostatDevice['properties']['available_climate_presets'][number] - export function useUpdateThermostatClimatePreset(): UseMutationResult< UseUpdateThermostatClimatePresetData, SeamHttpApiError, @@ -41,20 +36,6 @@ export function useUpdateThermostatClimatePreset(): UseMutationResult< await client.thermostats.createClimatePreset(variables) }, onSuccess: (_data, variables) => { - const preset: ClimatePreset = { - ...variables, - cooling_set_point_celsius: fhToCelsius( - variables.cooling_set_point_fahrenheit - ), - heating_set_point_celsius: fhToCelsius( - variables.heating_set_point_fahrenheit - ), - display_name: variables.name ?? variables.climate_preset_key, - can_delete: true, - can_edit: true, - manual_override_allowed: true, - } - queryClient.setQueryData( ['devices', 'get', { device_id: variables.device_id }], (device) => { @@ -62,7 +43,7 @@ export function useUpdateThermostatClimatePreset(): UseMutationResult< return } - return getUpdatedDevice(device, preset) + return getUpdatedDevice(device, variables) } ) @@ -75,7 +56,7 @@ export function useUpdateThermostatClimatePreset(): UseMutationResult< return devices.map((device) => { if (device.device_id === variables.device_id) { - return getUpdatedDevice(device, preset) + return getUpdatedDevice(device, variables) } return device @@ -88,10 +69,20 @@ export function useUpdateThermostatClimatePreset(): UseMutationResult< function getUpdatedDevice( device: ThermostatDevice, - preset: ClimatePreset + variables: UseUpdateThermostatClimatePresetVariables ): ThermostatDevice { - if (device == null) { - return device + const preset: ThermostatClimatePreset = { + ...variables, + cooling_set_point_celsius: fahrenheitToCelsius( + variables.cooling_set_point_fahrenheit + ), + heating_set_point_celsius: fahrenheitToCelsius( + variables.heating_set_point_fahrenheit + ), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: true, } return { diff --git a/src/lib/ui/thermostat/ClimateModeMenu.tsx b/src/lib/ui/thermostat/ClimateModeMenu.tsx index 0d9a868b1..a5fa680c9 100644 --- a/src/lib/ui/thermostat/ClimateModeMenu.tsx +++ b/src/lib/ui/thermostat/ClimateModeMenu.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames' +import type { CSSProperties } from 'react' import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' import { OffIcon } from 'lib/icons/Off.js' @@ -15,7 +16,7 @@ interface ClimateModeMenuProps { supportedModes?: HvacModeSetting[] buttonTextVisible?: boolean className?: string - style?: React.CSSProperties + style?: CSSProperties block?: boolean size?: 'regular' | 'large' } diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index aacf7e193..d225862bc 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -1,11 +1,14 @@ import classNames from 'classnames' -import { type HTMLAttributes, useMemo, useRef } from 'react' -import { Controller, useForm } from 'react-hook-form' +import { type HTMLAttributes, type Ref, useCallback, useImperativeHandle, useMemo, useRef } from 'react' +import { Controller, useForm, type UseFormReturn } from 'react-hook-form' import type { + FanModeSetting, HvacModeSetting, + ThermostatClimatePreset, ThermostatDevice, } from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' import { useCreateThermostatClimatePreset } from 'lib/seam/thermostats/use-create-thermostat-climate-preset.js' import { useUpdateThermostatClimatePreset } from 'lib/seam/thermostats/use-update-thermostat-climate-preset.js' import { Button } from 'lib/ui/Button.js' @@ -17,36 +20,54 @@ import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' -type Preset = - ThermostatDevice['properties']['available_climate_presets'][number] - export type ClimatePresetProps = { - preset?: Preset + preset?: ThermostatClimatePreset onBack: () => void device: ThermostatDevice } & Omit, 'children'> -const toCelcius = (t: number): number => (t - 32) * (5 / 9) - export function ClimatePreset(props: ClimatePresetProps): JSX.Element { - const { preset: _preset, onBack, device, ...attrs } = props - - const preset = useRef( - _preset ?? { - climate_preset_key: '', - can_edit: true, - can_delete: true, - display_name: '', - fan_mode_setting: 'auto', - hvac_mode_setting: 'off', - heating_set_point_fahrenheit: 80, - cooling_set_point_fahrenheit: 60, - heating_set_point_celsius: toCelcius(80), - cooling_set_point_celsius: toCelcius(60), - manual_override_allowed: true, - name: '', - } - ).current + const { preset, onBack, device, ...attrs } = props + + return ( +
+ + { + preset == null + ? + : + } +
+ ) +} + +interface PresetFormProps { + defaultValues: { + key: string + name: string + hvacMode: HvacModeSetting | undefined + heatPoint: number | undefined + coolPoint: number | undefined + fanMode: FanModeSetting | undefined + } + editable: boolean + deletable: boolean + onSubmit: (values: PresetFormProps['defaultValues']) => void + onDelete?: () => void + device: ThermostatDevice + loading: boolean + instanceRef?: Ref | undefined> + withKeyField?: boolean +} + +function PresetForm(props: PresetFormProps): JSX.Element { + const { defaultValues, device, deletable, editable, instanceRef, loading, onDelete, onSubmit, withKeyField } = props + const _form = useForm({ defaultValues }) + + useImperativeHandle(instanceRef, () => _form) const { register, @@ -55,149 +76,60 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { watch, setValue, control, - setError, - } = useForm({ - defaultValues: { - key: preset.climate_preset_key, - name: preset.display_name, - hvacMode: preset.hvac_mode_setting, - heatPoint: preset.heating_set_point_fahrenheit, - coolPoint: preset.cooling_set_point_fahrenheit, - fanMode: preset.fan_mode_setting, - }, - }) + } = _form const state = watch() const onHvacModeChange = (mode: HvacModeSetting): void => { if (mode === 'heat_cool') { - setValue('heatPoint', preset.heating_set_point_fahrenheit) - setValue('coolPoint', preset.cooling_set_point_fahrenheit) + setValue('heatPoint', defaultValues.heatPoint) + setValue('coolPoint', defaultValues.coolPoint) } else if (mode === 'heat') { - setValue('heatPoint', preset.heating_set_point_fahrenheit) + setValue('heatPoint', defaultValues.heatPoint) setValue('coolPoint', undefined) } else if (mode === 'cool') { setValue('heatPoint', undefined) - setValue('coolPoint', preset.cooling_set_point_fahrenheit) + setValue('coolPoint', defaultValues.coolPoint) } else { setValue('heatPoint', undefined) setValue('coolPoint', undefined) } } - const allPresets = useMemo( - () => device.properties.available_climate_presets ?? [], - [device] - ) const otherPresets = useMemo( - () => - allPresets.filter( - (other) => other.climate_preset_key !== preset.climate_preset_key - ), - [allPresets, preset] - ) + () => { + if (withKeyField !== true) return []; - const isCreate = _preset == null - const createMutation = useCreateThermostatClimatePreset() - const updateMutation = useUpdateThermostatClimatePreset() - const loading = isCreate ? createMutation.isPending : updateMutation.isPending - - const onSubmit = (): void => { - if ( - isCreate && - otherPresets.some((other) => other.climate_preset_key === state.key) - ) { - setError('key', { - type: 'validate', - message: 'Preset with this key already exists.', - }) - return - } - - const name = state.name.replace(/\s+/g, ' ').trim() - - const body = { - climate_preset_key: state.key, - device_id: device.device_id, - name: name === '' ? undefined : name, - cooling_set_point_fahrenheit: state.coolPoint, - heating_set_point_fahrenheit: state.heatPoint, - fan_mode_setting: state.fanMode, - cooling_set_point_celsius: - typeof state.coolPoint === 'number' - ? toCelcius(state.coolPoint) - : undefined, - heating_set_point_celsius: - typeof state.heatPoint === 'number' - ? toCelcius(state.heatPoint) - : undefined, - hvac_mode_setting: state.hvacMode, - } + return (device.properties.available_climate_presets ?? []).filter( + (other) => other.climate_preset_key !== defaultValues.key + ) + }, + [defaultValues, device, withKeyField] + ) - if (isCreate) { - createMutation.mutate(body) - } else { - updateMutation.mutate({ - ...body, - manual_override_allowed: true, - }) - } - } + const onValid = useCallback(() => { + onSubmit(state); + }, [onSubmit, state]) return ( -
- - -
- { - void handleSubmit(onSubmit)(e) - }} - > - {isCreate && ( - - Key - other.climate_preset_key === fixedValue - ) - }, - }), - }} - /> - - )} - +
+ { + void handleSubmit(onValid)(e) + }} + > + {withKeyField === true && ( - Display Name (Optional) + Key other.climate_preset_key === normalizedValue + ) + }, }), }} /> + )} + + {t.nameField} + + + + { + state.fanMode != null && ( + + {t.fanModeField} + ( + value != null ? ( + + ) : <> + )} + /> + + ) + } + + {state.hvacMode != null && ( - Fan Mode + {t.hvacModeField} ( - + value == null ? <> : { + onHvacModeChange(value) + onChange(value) + }} + /> )} /> + )} + {state.hvacMode !== 'off' && state.hvacMode != null && ( - HVAC Mode - ( - { - onHvacModeChange(value) - onChange(value) - }} - /> - )} + {t.heatCoolField} + { + setValue('heatPoint', value) + }} + onCoolValueChange={(value) => { + setValue('coolPoint', value) + }} + heatValue={state.heatPoint ?? 0} + coolValue={state.coolPoint ?? 0} + minHeat={65} + maxHeat={100} + minCool={50} + maxCool={90} + delta={5} /> + )} - {state.hvacMode !== 'off' && ( - - Heat / Cool - { - setValue('heatPoint', value) - }} - onCoolValueChange={(value) => { - setValue('coolPoint', value) - }} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - heatValue={state.heatPoint!} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - coolValue={state.coolPoint!} - minHeat={65} - maxHeat={100} - minCool={50} - maxCool={90} - delta={5} - /> - - )} - -
- - - -
- -
+
+ + + +
+
) } + +interface CreateFormProps { + device: ThermostatDevice + onComplete: () => void +} + +function CreateForm({ device, onComplete }: CreateFormProps): JSX.Element { + const instanceRef = useRef>() + const mutation = useCreateThermostatClimatePreset() + + const onSubmit = useCallback((values: PresetFormProps['defaultValues']) => { + if(instanceRef.current == null) return; + + const key = values.key.replace(/\s+/g, '').trim(); + const otherPresets = device.properties.available_climate_presets ?? []; + + if (otherPresets.some((other) => other.climate_preset_key === key)) { + instanceRef.current.setError('key', { + type: 'validate', + message: t.keyErrorMsg, + }) + return + } + + mutation.mutate({ + climate_preset_key: key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + }, { onSuccess: onComplete }) + }, [device, mutation, onComplete]) + + return +} + +interface UpdateFormProps { + device: ThermostatDevice + onComplete: () => void + preset: ThermostatClimatePreset +} + +function UpdateForm({ device, onComplete, preset }: UpdateFormProps): JSX.Element { + const mutation = useUpdateThermostatClimatePreset() + const defaultValues = useMemo(() => ({ + coolPoint: preset.cooling_set_point_fahrenheit ?? 60, + heatPoint: preset.heating_set_point_fahrenheit ?? 80, + name: preset.display_name, + hvacMode: preset.hvac_mode_setting, + fanMode: preset.fan_mode_setting, + key: preset.climate_preset_key, + }), [preset]) + + const onSubmit = useCallback((values: PresetFormProps['defaultValues']) => { + mutation.mutate({ + climate_preset_key: values.key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + manual_override_allowed: false, + }, { onSuccess: onComplete }) + }, [device, mutation, onComplete]) + + return +} + +const t = { + keyErrorMsg: "Preset with this key already exists.", + nameField: 'Display Name (Optional)', + fanModeField: 'Fan Mode', + hvacModeField: 'HVAC Mode', + heatCoolField: 'Heat / Cool', + delete: 'Delete', + save: 'Save', + max20Chars: 'max 20 chars', + min3Chars: 'min 3 chars', + crateNewPreset: 'Create New Preset', +} \ No newline at end of file diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx index d9f4d6b94..377b405d3 100644 --- a/src/lib/ui/thermostat/ClimatePresets.tsx +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -7,7 +7,8 @@ import { FanIcon } from 'lib/icons/Fan.js' import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' import { ThermostatHeatIcon } from 'lib/icons/ThermostatHeat.js' import { TrashIcon } from 'lib/icons/Trash.js' -import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { getTemperatureUnitSymbol } from 'lib/seam/thermostats/unit-conversion.js' import { useDeleteThermostatClimatePreset } from 'lib/seam/thermostats/use-delete-thermostat-climate-preset.js' import { Button } from 'lib/ui/Button.js' import { IconButton } from 'lib/ui/IconButton.js' @@ -15,7 +16,7 @@ import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' import { Spinner } from 'lib/ui/Spinner/Spinner.js' import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js' -export interface ClimatePresetsManagement { +interface ClimatePresetsManagement { device: ThermostatDevice onBack: () => void temperatureUnit: 'fahrenheit' | 'celsius' @@ -23,18 +24,15 @@ export interface ClimatePresetsManagement { const CreateNewPresetSymbol = Symbol('CreateNewPreset') -type Preset = - ThermostatDevice['properties']['available_climate_presets'][number] - export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { const { device, onBack } = props const [selectedClimatePreset, setSelectedClimatePreset] = useState< - Preset | typeof CreateNewPresetSymbol | null + ThermostatClimatePreset | typeof CreateNewPresetSymbol | null >(null) const [inDeletionPresetKey, setInDeletionPresetKey] = useState< - Preset['climate_preset_key'] | null + ThermostatClimatePreset['climate_preset_key'] | null >(null) const deleteMutation = useDeleteThermostatClimatePreset() @@ -59,7 +57,7 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { return (
- +
@@ -105,7 +103,7 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { function PresetCard( props: HTMLAttributes & { - preset: Preset + preset: ThermostatClimatePreset temperatureUnit: 'fahrenheit' | 'celsius' onClickEdit: () => void onClickDelete: () => void @@ -133,7 +131,7 @@ function PresetCard( ? preset.cooling_set_point_fahrenheit : (preset.cooling_set_point_celsius ?? undefined) - const unitSymbol = temperatureUnit === 'fahrenheit' ? '˚F' : '˚C' + const unitSymbol = getTemperatureUnitSymbol(temperatureUnit) const chips = ( [ @@ -177,6 +175,7 @@ function PresetCard( @@ -184,6 +183,7 @@ function PresetCard( {deletionLoading ? : } @@ -194,3 +194,10 @@ function PresetCard(
) } + +const t = { + title: 'Climate Presets', + createNew: 'Create New', + delete: 'Delete', + edit: 'Edit', +} From 2fa9724385600abbaafcf52b0b038dac66939358 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Fri, 7 Mar 2025 21:40:38 +0000 Subject: [PATCH 08/11] ci: Format code --- src/lib/seam/thermostats/thermostat-device.ts | 3 +- src/lib/seam/thermostats/unit-conversion.ts | 14 +- .../use-create-thermostat-climate-preset.ts | 7 +- .../use-update-thermostat-climate-preset.ts | 5 +- src/lib/ui/thermostat/ClimatePreset.tsx | 299 ++++++++++-------- src/lib/ui/thermostat/ClimatePresets.tsx | 5 +- 6 files changed, 195 insertions(+), 138 deletions(-) diff --git a/src/lib/seam/thermostats/thermostat-device.ts b/src/lib/seam/thermostats/thermostat-device.ts index 3422892a7..28c77502f 100644 --- a/src/lib/seam/thermostats/thermostat-device.ts +++ b/src/lib/seam/thermostats/thermostat-device.ts @@ -38,4 +38,5 @@ export const isThermostatDevice = ( device: Device ): device is ThermostatDevice => 'is_fan_running' in device.properties -export type ThermostatClimatePreset = ThermostatDevice['properties']['available_climate_presets'][number] +export type ThermostatClimatePreset = + ThermostatDevice['properties']['available_climate_presets'][number] diff --git a/src/lib/seam/thermostats/unit-conversion.ts b/src/lib/seam/thermostats/unit-conversion.ts index 73c4a44c2..414b4cd78 100644 --- a/src/lib/seam/thermostats/unit-conversion.ts +++ b/src/lib/seam/thermostats/unit-conversion.ts @@ -2,9 +2,11 @@ import type { Device } from '@seamapi/types/connect' type ConversionReturn = T extends NonNullable ? number : T -export const celsiusToFahrenheit = (t: T): ConversionReturn => (typeof t === 'number' ? ((t * 9) / 5 + 32) : t) as ConversionReturn +export const celsiusToFahrenheit = (t: T): ConversionReturn => + (typeof t === 'number' ? (t * 9) / 5 + 32 : t) as ConversionReturn -export const fahrenheitToCelsius = (t: T): ConversionReturn => (typeof t === 'number' ? ((t - 32) * (5 / 9)) : t) as ConversionReturn +export const fahrenheitToCelsius = (t: T): ConversionReturn => + (typeof t === 'number' ? (t - 32) * (5 / 9) : t) as ConversionReturn export const getCoolingSetPointCelsius = ( variables: { @@ -90,6 +92,8 @@ export const getHeatingSetPointFahrenheit = ( ) } -export function getTemperatureUnitSymbol(type: "fahrenheit" | "celsius"): string { - return type === "fahrenheit" ? "°F" : "°C" -} \ No newline at end of file +export function getTemperatureUnitSymbol( + type: 'fahrenheit' | 'celsius' +): string { + return type === 'fahrenheit' ? '°F' : '°C' +} diff --git a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts index 68a72fabb..cf53bbf51 100644 --- a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts @@ -8,7 +8,10 @@ import { useQueryClient, } from '@tanstack/react-query' -import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' @@ -67,7 +70,7 @@ export function useCreateThermostatClimatePreset(): UseMutationResult< }) } -const getUpdatedDevice =( +const getUpdatedDevice = ( device: ThermostatDevice, variables: UseCreateThermostatClimatePresetVariables ): ThermostatDevice => { diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts index 1f8b9dd99..6e9487f8d 100644 --- a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -8,7 +8,10 @@ import { useQueryClient, } from '@tanstack/react-query' -import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index d225862bc..cc39609f4 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -1,5 +1,12 @@ import classNames from 'classnames' -import { type HTMLAttributes, type Ref, useCallback, useImperativeHandle, useMemo, useRef } from 'react' +import { + type HTMLAttributes, + type Ref, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' import { Controller, useForm, type UseFormReturn } from 'react-hook-form' import type { @@ -34,12 +41,15 @@ export function ClimatePreset(props: ClimatePresetProps): JSX.Element { {...attrs} className={classNames('seam-thermostat-climate-preset', attrs.className)} > - - { - preset == null - ? - : - } + + {preset == null ? ( + + ) : ( + + )}
) } @@ -64,7 +74,17 @@ interface PresetFormProps { } function PresetForm(props: PresetFormProps): JSX.Element { - const { defaultValues, device, deletable, editable, instanceRef, loading, onDelete, onSubmit, withKeyField } = props + const { + defaultValues, + device, + deletable, + editable, + instanceRef, + loading, + onDelete, + onSubmit, + withKeyField, + } = props const _form = useForm({ defaultValues }) useImperativeHandle(instanceRef, () => _form) @@ -96,20 +116,16 @@ function PresetForm(props: PresetFormProps): JSX.Element { } } + const otherPresets = useMemo(() => { + if (withKeyField !== true) return [] - const otherPresets = useMemo( - () => { - if (withKeyField !== true) return []; - - return (device.properties.available_climate_presets ?? []).filter( - (other) => other.climate_preset_key !== defaultValues.key - ) - }, - [defaultValues, device, withKeyField] - ) + return (device.properties.available_climate_presets ?? []).filter( + (other) => other.climate_preset_key !== defaultValues.key + ) + }, [defaultValues, device, withKeyField]) const onValid = useCallback(() => { - onSubmit(state); + onSubmit(state) }, [onSubmit, state]) return ( @@ -172,27 +188,27 @@ function PresetForm(props: PresetFormProps): JSX.Element { /> - { - state.fanMode != null && ( - - {t.fanModeField} - ( - value != null ? ( - - ) : <> - )} - /> - - ) - } + {state.fanMode != null && ( + + {t.fanModeField} + + value != null ? ( + + ) : ( + <> + ) + } + /> + + )} {state.hvacMode != null && ( @@ -200,18 +216,22 @@ function PresetForm(props: PresetFormProps): JSX.Element { ( - value == null ? <> : { - onHvacModeChange(value) - onChange(value) - }} - /> - )} + render={({ field: { onChange, value } }) => + value == null ? ( + <> + ) : ( + { + onHvacModeChange(value) + onChange(value) + }} + /> + ) + } /> )} @@ -271,50 +291,58 @@ function CreateForm({ device, onComplete }: CreateFormProps): JSX.Element { const instanceRef = useRef>() const mutation = useCreateThermostatClimatePreset() - const onSubmit = useCallback((values: PresetFormProps['defaultValues']) => { - if(instanceRef.current == null) return; + const onSubmit = useCallback( + (values: PresetFormProps['defaultValues']) => { + if (instanceRef.current == null) return - const key = values.key.replace(/\s+/g, '').trim(); - const otherPresets = device.properties.available_climate_presets ?? []; + const key = values.key.replace(/\s+/g, '').trim() + const otherPresets = device.properties.available_climate_presets ?? [] - if (otherPresets.some((other) => other.climate_preset_key === key)) { - instanceRef.current.setError('key', { - type: 'validate', - message: t.keyErrorMsg, - }) - return - } + if (otherPresets.some((other) => other.climate_preset_key === key)) { + instanceRef.current.setError('key', { + type: 'validate', + message: t.keyErrorMsg, + }) + return + } + + mutation.mutate( + { + climate_preset_key: key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + }, + { onSuccess: onComplete } + ) + }, + [device, mutation, onComplete] + ) - mutation.mutate({ - climate_preset_key: key, - device_id: device.device_id, - name: values.name === '' ? undefined : values.name, - cooling_set_point_fahrenheit: values.coolPoint, - heating_set_point_fahrenheit: values.heatPoint, - fan_mode_setting: values.fanMode, - cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), - heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), - hvac_mode_setting: values.hvacMode, - }, { onSuccess: onComplete }) - }, [device, mutation, onComplete]) - - return + return ( + + ) } interface UpdateFormProps { @@ -323,44 +351,59 @@ interface UpdateFormProps { preset: ThermostatClimatePreset } -function UpdateForm({ device, onComplete, preset }: UpdateFormProps): JSX.Element { +function UpdateForm({ + device, + onComplete, + preset, +}: UpdateFormProps): JSX.Element { const mutation = useUpdateThermostatClimatePreset() - const defaultValues = useMemo(() => ({ - coolPoint: preset.cooling_set_point_fahrenheit ?? 60, - heatPoint: preset.heating_set_point_fahrenheit ?? 80, - name: preset.display_name, - hvacMode: preset.hvac_mode_setting, - fanMode: preset.fan_mode_setting, - key: preset.climate_preset_key, - }), [preset]) - - const onSubmit = useCallback((values: PresetFormProps['defaultValues']) => { - mutation.mutate({ - climate_preset_key: values.key, - device_id: device.device_id, - name: values.name === '' ? undefined : values.name, - cooling_set_point_fahrenheit: values.coolPoint, - heating_set_point_fahrenheit: values.heatPoint, - fan_mode_setting: values.fanMode, - cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), - heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), - hvac_mode_setting: values.hvacMode, - manual_override_allowed: false, - }, { onSuccess: onComplete }) - }, [device, mutation, onComplete]) - - return + const defaultValues = useMemo( + () => ({ + coolPoint: preset.cooling_set_point_fahrenheit ?? 60, + heatPoint: preset.heating_set_point_fahrenheit ?? 80, + name: preset.display_name, + hvacMode: preset.hvac_mode_setting, + fanMode: preset.fan_mode_setting, + key: preset.climate_preset_key, + }), + [preset] + ) + + const onSubmit = useCallback( + (values: PresetFormProps['defaultValues']) => { + mutation.mutate( + { + climate_preset_key: values.key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + manual_override_allowed: false, + }, + { onSuccess: onComplete } + ) + }, + [device, mutation, onComplete] + ) + + return ( + + ) } const t = { - keyErrorMsg: "Preset with this key already exists.", + keyErrorMsg: 'Preset with this key already exists.', nameField: 'Display Name (Optional)', fanModeField: 'Fan Mode', hvacModeField: 'HVAC Mode', @@ -370,4 +413,4 @@ const t = { max20Chars: 'max 20 chars', min3Chars: 'min 3 chars', crateNewPreset: 'Create New Preset', -} \ No newline at end of file +} diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx index 377b405d3..a312d1c29 100644 --- a/src/lib/ui/thermostat/ClimatePresets.tsx +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -7,7 +7,10 @@ import { FanIcon } from 'lib/icons/Fan.js' import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' import { ThermostatHeatIcon } from 'lib/icons/ThermostatHeat.js' import { TrashIcon } from 'lib/icons/Trash.js' -import type { ThermostatClimatePreset, ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' import { getTemperatureUnitSymbol } from 'lib/seam/thermostats/unit-conversion.js' import { useDeleteThermostatClimatePreset } from 'lib/seam/thermostats/use-delete-thermostat-climate-preset.js' import { Button } from 'lib/ui/Button.js' From 2b87d66f8c391f00c09a79167add482f3726933d Mon Sep 17 00:00:00 2001 From: kadiryazici Date: Sat, 8 Mar 2025 00:43:39 +0300 Subject: [PATCH 09/11] fix button state --- src/lib/ui/thermostat/ClimatePreset.tsx | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index cc39609f4..8b383467c 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -63,7 +63,6 @@ interface PresetFormProps { coolPoint: number | undefined fanMode: FanModeSetting | undefined } - editable: boolean deletable: boolean onSubmit: (values: PresetFormProps['defaultValues']) => void onDelete?: () => void @@ -78,7 +77,6 @@ function PresetForm(props: PresetFormProps): JSX.Element { defaultValues, device, deletable, - editable, instanceRef, loading, onDelete, @@ -259,19 +257,23 @@ function PresetForm(props: PresetFormProps): JSX.Element { )}
- + { + deletable && ( + + ) + } - ) - } + {deletable && ( + + )} + + +
+
+ ) +} + +const t = { + confirm: 'Confirm', + cancel: 'Cancel', + areYouSure: 'Are you sure?', +} diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx index 5230e2791..2b4e82c42 100644 --- a/src/lib/ui/thermostat/ClimatePreset.tsx +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -63,9 +63,7 @@ interface PresetFormProps { coolPoint: number | undefined fanMode: FanModeSetting | undefined } - deletable: boolean onSubmit: (values: PresetFormProps['defaultValues']) => void - onDelete?: () => void device: ThermostatDevice loading: boolean instanceRef?: Ref | undefined> @@ -76,10 +74,8 @@ function PresetForm(props: PresetFormProps): JSX.Element { const { defaultValues, device, - deletable, instanceRef, loading, - onDelete, onSubmit, withKeyField, } = props @@ -257,17 +253,6 @@ function PresetForm(props: PresetFormProps): JSX.Element { )}
- {deletable && ( - - )} -
diff --git a/src/styles/_main.scss b/src/styles/_main.scss index ff634d820..53403d84a 100644 --- a/src/styles/_main.scss +++ b/src/styles/_main.scss @@ -30,6 +30,7 @@ @use './tab-set'; @use './noise-sensor'; @use './seam-editable-device-name'; +@use './popover'; .seam-components { // Reset @@ -68,4 +69,5 @@ @include thermostat.all; @include seam-table.all; @include noise-sensor.all; + @include popover.all; } diff --git a/src/styles/_popover.scss b/src/styles/_popover.scss new file mode 100644 index 000000000..5a0ed570a --- /dev/null +++ b/src/styles/_popover.scss @@ -0,0 +1,46 @@ +@use './colors'; + +@mixin popover { + .seam-popover { + display: inline-block; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 10px -2px rgb(0 0 0 / 20%); + background-color: colors.$bg-a; + border: 1px solid colors.$bg-c; + } +} + +@mixin popover-content-prompt { + .seam-popover-content-prompt { + display: inline-flex; + flex-flow: column nowrap; + padding: 12px; + gap: 12px; + background-color: colors.$bg-a; + } + + .seam-popover-content-prompt-text { + font-weight: 600; + text-align: center; + } + + .seam-popover-content-prompt-description { + font-weight: 400; + text-align: center; + color: colors.$text-gray-2; + font-size: 12px; + } + + .seam-popover-content-prompt-buttons { + display: flex; + flex-flow: row nowrap; + gap: 6px; + justify-content: flex-end; + } +} + +@mixin all { + @include popover; + @include popover-content-prompt; +}