Skip to content
125 changes: 97 additions & 28 deletions src/lib/ui/thermostat/ClimatePreset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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 { Snackbar } from 'lib/ui/Snackbar/Snackbar.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'
Expand Down Expand Up @@ -265,12 +266,35 @@ interface CreateFormProps {
onComplete: () => void
}

/**
* @see https://github.com/seamapi/seam-connect/blob/a0b081e086e6f031ad3bcac6dd3f27c46e5b54e5/pages/api/public/thermostats/create_climate_preset.ts#L78-L81
**/
const CreateClimatePresetErrorCodes = {
DeviceNotFound: 'device_not_found',
ClimatePresetExists: 'climate_preset_exists',
}

function CreateForm({ device, onComplete }: CreateFormProps): JSX.Element {
const mutation = useCreateThermostatClimatePreset()
const { mutate, isError, error, isPending } =
useCreateThermostatClimatePreset()

const errorMessage = useMemo(() => {
if (!isError) return ''

if (error?.code === CreateClimatePresetErrorCodes.ClimatePresetExists) {
return t.keyAlreadyExists
}

if (error?.code === CreateClimatePresetErrorCodes.DeviceNotFound) {
return t.deviceNotFound
}

return t.unknownErrorOccured
}, [error, isError])

const onSubmit = useCallback(
(values: PresetFormProps['defaultValues']) => {
mutation.mutate(
mutate(
{
climate_preset_key: values.key,
device_id: device.device_id,
Expand All @@ -285,24 +309,33 @@ function CreateForm({ device, onComplete }: CreateFormProps): JSX.Element {
{ onSuccess: onComplete }
)
},
[device, mutation, onComplete]
[device, mutate, onComplete]
)

return (
<PresetForm
defaultValues={{
key: '',
coolPoint: 60,
heatPoint: 80,
name: '',
hvacMode: 'off',
fanMode: 'auto',
}}
device={device}
loading={mutation.isPending}
onSubmit={onSubmit}
withKeyField
/>
<>
<Snackbar
message={errorMessage}
variant='error'
visible={isError}
automaticVisibility
/>

<PresetForm
defaultValues={{
key: '',
coolPoint: 60,
heatPoint: 80,
name: '',
hvacMode: 'off',
fanMode: 'auto',
}}
device={device}
loading={isPending}
onSubmit={onSubmit}
withKeyField
/>
</>
)
}

Expand All @@ -312,16 +345,26 @@ interface UpdateFormProps {
preset: ThermostatClimatePreset
}

/**
* @see https://github.com/seamapi/seam-connect/blob/a0b081e086e6f031ad3bcac6dd3f27c46e5b54e5/pages/api/public/thermostats/update_climate_preset.ts
**/
const UpdateClimatePresetErrorCodes = {
DeviceNotFound: 'device_not_found',
ClimatePresetNotFound: 'climate_preset_not_found',
}

function UpdateForm({
device,
onComplete,
preset,
}: UpdateFormProps): JSX.Element {
const mutation = useUpdateThermostatClimatePreset()
const { mutate, isError, error, isPending } =
useUpdateThermostatClimatePreset()

const defaultValues = useMemo<PresetFormProps['defaultValues']>(
() => ({
coolPoint: preset.cooling_set_point_fahrenheit ?? 60,
heatPoint: preset.heating_set_point_fahrenheit ?? 80,
coolPoint: preset.cooling_set_point_fahrenheit,
heatPoint: preset.heating_set_point_fahrenheit,
name: preset.display_name,
hvacMode: preset.hvac_mode_setting,
fanMode: preset.fan_mode_setting,
Expand All @@ -332,7 +375,7 @@ function UpdateForm({

const onSubmit = useCallback(
(values: PresetFormProps['defaultValues']) => {
mutation.mutate(
mutate(
{
climate_preset_key: values.key,
device_id: device.device_id,
Expand All @@ -347,27 +390,53 @@ function UpdateForm({
{ onSuccess: onComplete }
)
},
[device, mutation, onComplete]
[device, mutate, onComplete]
)

const errorMessage = useMemo(() => {
if (!isError) return ''

if (error?.code === UpdateClimatePresetErrorCodes.ClimatePresetNotFound) {
return t.climatePresetNotFound
}

if (error?.code === UpdateClimatePresetErrorCodes.DeviceNotFound) {
return t.deviceNotFound
}

return t.unknownErrorOccured
}, [error, isError])

return (
<PresetForm
defaultValues={defaultValues}
device={device}
loading={mutation.isPending}
onSubmit={onSubmit}
/>
<>
<Snackbar
message={errorMessage}
variant='error'
visible={isError}
automaticVisibility
/>

<PresetForm
defaultValues={defaultValues}
device={device}
loading={isPending}
onSubmit={onSubmit}
/>
</>
)
}

const t = {
keyAlreadyExists: 'Climate Preset with this key already exists.',
keyCannotContainSpaces: 'Climate Preset key cannot contain spaces.',
climatePresetNotFound: 'Climate Preset not found.',
deviceNotFound: 'Device not found.',
nameField: 'Display Name (Optional)',
fanModeField: 'Fan Mode',
hvacModeField: 'HVAC Mode',
heatCoolField: 'Heat / Cool',
delete: 'Delete',
save: 'Save',
crateNewPreset: 'Create New Climate Preset',
unknownErrorOccured: 'An unknown error occurred.',
}
138 changes: 92 additions & 46 deletions src/lib/ui/thermostat/ClimatePresets.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classNames from 'classnames'
import { type HTMLAttributes, type ReactNode, useState } from 'react'
import { type HTMLAttributes, type ReactNode, useMemo, useState } from 'react'

import { AddIcon } from 'lib/icons/Add.js'
import { EditIcon } from 'lib/icons/Edit.js'
Expand All @@ -18,6 +18,7 @@ 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 { Snackbar } from 'lib/ui/Snackbar/Snackbar.js'
import { Spinner } from 'lib/ui/Spinner/Spinner.js'
import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js'

Expand All @@ -29,6 +30,15 @@ interface ClimatePresetsManagement {

const CreateNewPresetSymbol = Symbol('CreateNewPreset')

/**
* @see https://github.com/seamapi/seam-connect/blob/a0b081e086e6f031ad3bcac6dd3f27c46e5b54e5/pages/api/public/thermostats/delete_climate_preset.ts
*/
const DeleteClimatePresetErrorCodes = {
ClimatePresetNotFound: 'climate_preset_not_found',
DeviceNotFound: 'device_not_found',
ClimatePresetIsScheduled: 'climate_preset_is_scheduled',
}

export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element {
const { device, onBack } = props

Expand All @@ -40,7 +50,29 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element {
climatePresetKeySelectedForDeletion,
setClimatePresetKeySelectedForDeletion,
] = useState<ThermostatClimatePreset['climate_preset_key'] | null>(null)
const deleteMutation = useDeleteThermostatClimatePreset()

const { mutate, isError, error, isPending } =
useDeleteThermostatClimatePreset()

const errorMessage = useMemo(() => {
if (!isError) return ''

if (error?.code === DeleteClimatePresetErrorCodes.ClimatePresetNotFound) {
return t.climatePresetNotFound
}

if (error?.code === DeleteClimatePresetErrorCodes.DeviceNotFound) {
return t.deviceNotFound
}

if (
error?.code === DeleteClimatePresetErrorCodes.ClimatePresetIsScheduled
) {
return t.climatePresetIsScheduled
}

return t.unknownErrorOccured
}, [error, isError])

if (
selectedClimatePreset != null ||
Expand All @@ -62,52 +94,61 @@ export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element {
}

return (
<div className='seam-thermostat-climate-presets'>
<ContentHeader title={t.title} onBack={onBack} />
<div className='seam-thermostat-climate-presets-body'>
<Button
onClick={() => {
setSelectedClimatePreset(CreateNewPresetSymbol)
}}
className='seam-climate-presets-add-button'
>
<AddIcon />
{t.createNew}
</Button>

<div className='seam-thermostat-climate-presets-cards'>
{device.properties.available_climate_presets.map((preset) => (
<PresetCard
onClickEdit={() => {
setSelectedClimatePreset(preset)
}}
onClickDelete={() => {
setClimatePresetKeySelectedForDeletion(
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 &&
climatePresetKeySelectedForDeletion ===
preset.climate_preset_key
}
disabled={
deleteMutation.isPending &&
climatePresetKeySelectedForDeletion !==
preset.climate_preset_key
}
/>
))}
<>
<Snackbar
message={errorMessage}
variant='error'
visible={isError}
automaticVisibility
/>

<div className='seam-thermostat-climate-presets'>
<ContentHeader title={t.title} onBack={onBack} />
<div className='seam-thermostat-climate-presets-body'>
<Button
onClick={() => {
setSelectedClimatePreset(CreateNewPresetSymbol)
}}
className='seam-climate-presets-add-button'
>
<AddIcon />
{t.createNew}
</Button>

<div className='seam-thermostat-climate-presets-cards'>
{device.properties.available_climate_presets.map((preset) => (
<PresetCard
onClickEdit={() => {
setSelectedClimatePreset(preset)
}}
onClickDelete={() => {
setClimatePresetKeySelectedForDeletion(
preset.climate_preset_key
)
mutate({
climate_preset_key: preset.climate_preset_key,
device_id: device.device_id,
})
}}
temperatureUnit={props.temperatureUnit}
preset={preset}
key={preset.climate_preset_key}
deletionLoading={
isPending &&
climatePresetKeySelectedForDeletion ===
preset.climate_preset_key
}
disabled={
isPending &&
climatePresetKeySelectedForDeletion !==
preset.climate_preset_key
}
/>
))}
</div>
</div>
</div>
</div>
</>
)
}

Expand Down Expand Up @@ -232,4 +273,9 @@ const t = {
createNew: 'Create New',
delete: 'Delete',
edit: 'Edit',
unknownErrorOccured: 'An unknown error occurred.',
climatePresetNotFound: 'Climate Preset not found.',
deviceNotFound: 'Device not found.',
climatePresetIsScheduled:
'The climate preset has upcoming schedules and cannot be deleted.',
}
Loading