diff --git a/.storybook/seed-fake.js b/.storybook/seed-fake.js index 15e258c37..9cba1b6ea 100644 --- a/.storybook/seed-fake.js +++ b/.storybook/seed-fake.js @@ -412,6 +412,34 @@ 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, + can_edit: true, + can_delete: true, + }, + { + 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, + can_edit: false, + can_delete: false, + }, + ], }, 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..5a27f508a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.6.0", "license": "MIT", "dependencies": { + "@floating-ui/react": "^0.27.5", "@seamapi/http": "^1.20.0", "@tanstack/react-query": "^5.27.5", "classnames": "^2.3.2", @@ -25,7 +26,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", @@ -3008,7 +3009,6 @@ "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.8" @@ -3018,18 +3018,30 @@ "version": "1.6.12", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.8" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.5.tgz", + "integrity": "sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.0.0" @@ -3040,11 +3052,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", - "dev": true, - "license": "MIT" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", @@ -5814,11 +5824,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" }, @@ -21591,7 +21600,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -22468,7 +22476,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -23882,6 +23889,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/table": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", diff --git a/package.json b/package.json index 4919d7061..617758ff0 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ } }, "dependencies": { + "@floating-ui/react": "^0.27.5", "@seamapi/http": "^1.20.0", "@tanstack/react-query": "^5.27.5", "classnames": "^2.3.2", @@ -143,7 +144,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..c2c7c126a 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,16 +328,38 @@ 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!', 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/thermostat-device.ts b/src/lib/seam/thermostats/thermostat-device.ts index 000cee4bf..28c77502f 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' > > } @@ -36,3 +37,6 @@ 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..414b4cd78 100644 --- a/src/lib/seam/thermostats/unit-conversion.ts +++ b/src/lib/seam/thermostats/unit-conversion.ts @@ -1,8 +1,12 @@ 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 +91,9 @@ export const getHeatingSetPointFahrenheit = ( undefined ) } + +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 new file mode 100644 index 000000000..cf53bbf51 --- /dev/null +++ b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts @@ -0,0 +1,101 @@ +import type { + SeamHttpApiError, + ThermostatsCreateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +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 +export type UseCreateThermostatClimatePresetData = undefined + +export type UseCreateThermostatClimatePresetVariables = + ThermostatsCreateClimatePresetBody + +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) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables) + } + ) + + 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) + } + + return device + }) + } + ) + }, + }) +} + +const getUpdatedDevice = ( + device: ThermostatDevice, + 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 { + ...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..9181d7f95 --- /dev/null +++ b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts @@ -0,0 +1,84 @@ +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) + } + ) + + 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) + } + + return device + }) + } + ) + }, + }) +} + +function getUpdatedDevice( + device: ThermostatDevice, + variables: UseDeleteThermostatClimatePresetVariables +): ThermostatDevice { + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: + 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 new file mode 100644 index 000000000..6e9487f8d --- /dev/null +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -0,0 +1,101 @@ +import type { + SeamHttpApiError, + ThermostatsUpdateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +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 +export type UseUpdateThermostatClimatePresetData = undefined + +export type UseUpdateThermostatClimatePresetVariables = + ThermostatsUpdateClimatePresetBody + +export function useUpdateThermostatClimatePreset(): 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) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables) + } + ) + + 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) + } + + return device + }) + } + ) + }, + }) +} + +function getUpdatedDevice( + device: ThermostatDevice, + variables: UseUpdateThermostatClimatePresetVariables +): 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: true, + } + + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: [ + preset, + ...(device.properties.available_climate_presets ?? []), + ], + }, + } +} diff --git a/src/lib/ui/Button.tsx b/src/lib/ui/Button.tsx index c074c2b38..65762fd04 100644 --- a/src/lib/ui/Button.tsx +++ b/src/lib/ui/Button.tsx @@ -1,14 +1,17 @@ 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' + variant?: 'solid' | 'outline' | 'neutral' | 'danger' size?: 'small' | 'medium' | 'large' type?: 'button' | 'submit' disabled?: boolean onClick?: MouseEventHandler className?: string onMouseDown?: MouseEventHandler + loading?: boolean } export function Button({ @@ -20,6 +23,7 @@ export function Button({ className, onMouseDown, type = 'button', + loading = false, }: ButtonProps): JSX.Element { return ( ) } diff --git a/src/lib/ui/IconButton.tsx b/src/lib/ui/IconButton.tsx index f8ccb78cf..cf2c71092 100644 --- a/src/lib/ui/IconButton.tsx +++ b/src/lib/ui/IconButton.tsx @@ -1,9 +1,26 @@ import classNames from 'classnames' +import type { Ref } from 'react' import type { ButtonProps } from 'lib/ui/types.js' -export function IconButton({ className, ...props }: ButtonProps): JSX.Element { +export type IconProps = ButtonProps & { + elRef?: Ref +} + +export function IconButton({ + className, + elRef, + ...props +}: IconProps): JSX.Element { return ( - + + +
+
+ ) +} + +const t = { + confirm: 'Confirm', + cancel: 'Cancel', + areYouSure: 'Are you sure?', +} diff --git a/src/lib/ui/thermostat/ClimateModeMenu.tsx b/src/lib/ui/thermostat/ClimateModeMenu.tsx index 1c3289ab8..a5fa680c9 100644 --- a/src/lib/ui/thermostat/ClimateModeMenu.tsx +++ b/src/lib/ui/thermostat/ClimateModeMenu.tsx @@ -1,3 +1,6 @@ +import classNames from 'classnames' +import type { CSSProperties } from 'react' + import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' import { OffIcon } from 'lib/icons/Off.js' import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' @@ -11,20 +14,49 @@ interface ClimateModeMenuProps { mode: HvacModeSetting onChange: (mode: HvacModeSetting) => void supportedModes?: HvacModeSetting[] + buttonTextVisible?: boolean + className?: string + style?: CSSProperties + block?: boolean + size?: 'regular' | 'large' } export function ClimateModeMenu({ mode, onChange, supportedModes = ['heat', 'cool', 'heat_cool', 'off'], + buttonTextVisible = false, + className, + style, + block, + size = 'regular', }: ClimateModeMenuProps): 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..2b4e82c42 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -0,0 +1,397 @@ +import classNames from 'classnames' +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' +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' + +export type ClimatePresetProps = { + preset?: ThermostatClimatePreset + onBack: () => void + device: ThermostatDevice +} & Omit, 'children'> + +export function ClimatePreset(props: ClimatePresetProps): JSX.Element { + 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 + } + onSubmit: (values: PresetFormProps['defaultValues']) => void + device: ThermostatDevice + loading: boolean + instanceRef?: Ref | undefined> + withKeyField?: boolean +} + +function PresetForm(props: PresetFormProps): JSX.Element { + const { + defaultValues, + device, + instanceRef, + loading, + onSubmit, + withKeyField, + } = props + const _form = useForm({ defaultValues }) + + useImperativeHandle(instanceRef, () => _form) + + const { + register, + handleSubmit, + formState: { errors }, + watch, + setValue, + control, + } = _form + + const state = watch() + + const onHvacModeChange = (mode: HvacModeSetting): void => { + if (mode === 'heat_cool') { + setValue('heatPoint', defaultValues.heatPoint) + setValue('coolPoint', defaultValues.coolPoint) + } else if (mode === 'heat') { + setValue('heatPoint', defaultValues.heatPoint) + setValue('coolPoint', undefined) + } else if (mode === 'cool') { + setValue('heatPoint', undefined) + setValue('coolPoint', defaultValues.coolPoint) + } else { + setValue('heatPoint', undefined) + setValue('coolPoint', undefined) + } + } + + const otherPresets = useMemo(() => { + if (withKeyField !== true) return [] + + return (device.properties.available_climate_presets ?? []).filter( + (other) => other.climate_preset_key !== defaultValues.key + ) + }, [defaultValues, device, withKeyField]) + + const onValid = useCallback(() => { + onSubmit(state) + }, [onSubmit, state]) + + return ( +
+
{ + void handleSubmit(onValid)(e) + }} + > + {withKeyField === true && ( + + Key + other.climate_preset_key === normalizedValue + ) + }, + }), + }} + /> + + )} + + + {t.nameField} + + + + {state.fanMode != null && ( + + {t.fanModeField} + + value != null ? ( + + ) : ( + <> + ) + } + /> + + )} + + {state.hvacMode != null && ( + + {t.hvacModeField} + + value == null ? ( + <> + ) : ( + { + onHvacModeChange(value) + onChange(value) + }} + /> + ) + } + /> + + )} + + {state.hvacMode !== 'off' && state.hvacMode != null && ( + + {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} + /> + + )} + +
+ +
+
+
+ ) +} + +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', +} diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx new file mode 100644 index 000000000..9afeaef64 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -0,0 +1,222 @@ +import classNames from 'classnames' +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 { + 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' +import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { Popover } from 'lib/ui/Popover/Popover.js' +import { PopoverContentPrompt } from 'lib/ui/Popover/PopoverContentPrompt.js' +import { Spinner } from 'lib/ui/Spinner/Spinner.js' +import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js' + +interface ClimatePresetsManagement { + device: ThermostatDevice + onBack: () => void + temperatureUnit: 'fahrenheit' | 'celsius' +} + +const CreateNewPresetSymbol = Symbol('CreateNewPreset') + +export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { + const { device, onBack } = props + + const [selectedClimatePreset, setSelectedClimatePreset] = useState< + ThermostatClimatePreset | typeof CreateNewPresetSymbol | null + >(null) + + const [inDeletionPresetKey, setInDeletionPresetKey] = useState< + ThermostatClimatePreset['climate_preset_key'] | null + >(null) + const deleteMutation = useDeleteThermostatClimatePreset() + + if ( + selectedClimatePreset != null || + selectedClimatePreset === CreateNewPresetSymbol + ) { + return ( + { + setSelectedClimatePreset(null) + }} + device={device} + preset={ + selectedClimatePreset === CreateNewPresetSymbol + ? undefined + : selectedClimatePreset + } + /> + ) + } + + return ( +
+ +
+ + +
+ {device.properties.available_climate_presets.map((preset) => ( + { + setSelectedClimatePreset(preset) + }} + onClickDelete={() => { + 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 + } + /> + ))} +
+
+
+ ) +} + +function PresetCard( + props: HTMLAttributes & { + preset: ThermostatClimatePreset + temperatureUnit: 'fahrenheit' | 'celsius' + onClickEdit: () => void + onClickDelete: () => void + deletionLoading?: boolean + disabled?: boolean + } +): JSX.Element { + const { + preset, + temperatureUnit, + onClickEdit, + onClickDelete, + deletionLoading = false, + disabled = false, + ...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 = getTemperatureUnitSymbol(temperatureUnit) + + const chips = ( + [ + heatPoint != null + ? { icon: , value: `${heatPoint} ${unitSymbol}` } + : undefined, + coolPoint != null + ? { icon: , value: `${coolPoint} ${unitSymbol}` } + : undefined, + preset.fan_mode_setting != null + ? { icon: , value: preset.fan_mode_setting } + : undefined, + ].filter(Boolean) as Array<{ icon: ReactNode; value: string }> + ).map(({ icon, value }, index) => ( +
+ {icon} + {value} +
+ )) + + return ( +
+
+
+ {preset.display_name} + + {preset.name != null && ( +
+ {preset.climate_preset_key} +
+ )} +
+ +
+ + + + + ( + { + onClickDelete() + hide() + }} + /> + )} + > + {({ setRef }) => ( + + {deletionLoading ? : } + + )} + +
+
+ +
{chips}
+
+ ) +} + +const t = { + title: 'Climate Presets', + createNew: 'Create New', + delete: 'Delete', + edit: 'Edit', +} diff --git a/src/lib/ui/thermostat/FanModeMenu.tsx b/src/lib/ui/thermostat/FanModeMenu.tsx index e8c3b2780..6c1cc126c 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,29 @@ 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 ( ( -