{t.device}
-
{device.properties.name}
+
{t.status}:{' '}
diff --git a/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx
index 8e1f298d4..cdf580776 100644
--- a/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx
+++ b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react'
import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js'
import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js'
import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js'
+import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
import type { NoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js'
import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
import { NoiseLevelStatus } from 'lib/ui/device/NoiseLevelStatus.js'
@@ -18,6 +19,7 @@ type TabType = 'details' | 'activity'
interface NoiseSensorDeviceDetailsProps
extends NestedSpecificDeviceDetailsProps {
device: NoiseSensorDevice
+ onEditName?: (newName: string) => void | Promise
}
export function NoiseSensorDeviceDetails({
@@ -26,6 +28,7 @@ export function NoiseSensorDeviceDetails({
disableResourceIds,
onBack,
className,
+ onEditName,
}: NoiseSensorDeviceDetailsProps): JSX.Element | null {
const [tab, setTab] = useState('details')
@@ -45,7 +48,11 @@ export function NoiseSensorDeviceDetails({
{t.noiseSensor}
-
{device.properties.name}
+
{t.status}:{' '}
diff --git a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx
index 32458e129..3379bcd1e 100644
--- a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx
+++ b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx
@@ -29,6 +29,7 @@ import { ThermostatCard } from 'lib/ui/thermostat/ThermostatCard.js'
interface ThermostatDeviceDetailsProps
extends NestedSpecificDeviceDetailsProps {
device: ThermostatDevice
+ onEditName?: (newName: string) => void | Promise
}
export function ThermostatDeviceDetails({
@@ -37,6 +38,7 @@ export function ThermostatDeviceDetails({
disableConnectedAccountInformation,
onBack,
className,
+ onEditName,
}: ThermostatDeviceDetailsProps): JSX.Element | null {
if (device == null) {
return null
@@ -47,7 +49,7 @@ export function ThermostatDeviceDetails({
-
+
diff --git a/src/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.tsx b/src/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.tsx
new file mode 100644
index 000000000..e69bb56e5
--- /dev/null
+++ b/src/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.tsx
@@ -0,0 +1,208 @@
+import classNames from 'classnames'
+import {
+ type ChangeEvent,
+ type HTMLAttributes,
+ type KeyboardEvent,
+ type PropsWithChildren,
+ useCallback,
+ useState,
+} from 'react'
+
+import { CheckIcon } from 'lib/icons/Check.js'
+import { CloseIcon } from 'lib/icons/Close.js'
+import { EditIcon } from 'lib/icons/Edit.js'
+
+export type SeamDeviceNameProps = {
+ onEdit?: (newName: string) => void
+ editable?: boolean
+ tagName?: string
+ value: string
+} & HTMLAttributes
+
+function IconButton(
+ props: PropsWithChildren>
+): JSX.Element {
+ return (
+
+ )
+}
+
+const fixName = (name: string): string => {
+ return name.replace(/\s+/g, ' ').trim()
+}
+
+type Result = { type: 'success' } | { type: 'error'; message: string }
+
+const isValidName = (name: string): Result => {
+ if (name.length < 2) {
+ return {
+ type: 'error',
+ message: 'Name must be at least 2 characters long',
+ }
+ }
+
+ if (name.length > 64) {
+ return {
+ type: 'error',
+ message: 'Name must be at most 64 characters long',
+ }
+ }
+
+ return {
+ type: 'success',
+ } as const
+}
+
+export function SeamEditableDeviceName({
+ onEdit,
+ editable = true,
+ tagName,
+ value,
+ ...props
+}: SeamDeviceNameProps): JSX.Element {
+ const [editing, setEditing] = useState(false)
+ const [errorText, setErrorText] = useState(null)
+ const [currentValue, setCurrentValue] = useState(value)
+ const Tag = (tagName ?? 'span') as 'div'
+
+ const handleCheck = useCallback(() => {
+ const fixedName = fixName(currentValue)
+ const valid = isValidName(fixedName)
+
+ if (valid.type === 'error') {
+ setErrorText(valid.message)
+ return
+ }
+
+ setEditing(false)
+ setCurrentValue(fixedName)
+ onEdit?.(fixedName)
+ }, [currentValue, onEdit])
+
+ const handleChange = useCallback(
+ (event: ChangeEvent): void => {
+ setCurrentValue(event.target.value)
+ setErrorText(null)
+ },
+ []
+ )
+
+ const handleCancel = useCallback(() => {
+ setEditing(false)
+ setCurrentValue(value)
+ setErrorText(null)
+ }, [value])
+
+ const handleInputKeydown = useCallback(
+ (e: KeyboardEvent): void => {
+ if (e.repeat) return
+
+ if (e.key === 'Enter') {
+ handleCheck()
+ } else if (e.key === 'Escape') {
+ handleCancel()
+ }
+ },
+ [handleCheck, handleCancel]
+ )
+
+ return (
+
+
+
+ {editable && (
+
+ {
+ setEditing(true)
+ }}
+ onCancel={handleCancel}
+ onCheck={handleCheck}
+ />
+
+ )}
+
+ )
+}
+
+interface NameViewProps {
+ editing: boolean
+ value: string
+ onChange: (event: ChangeEvent) => void
+ onKeyDown: (event: KeyboardEvent) => void
+ errorText?: string | null
+}
+
+function NameView(props: NameViewProps): JSX.Element {
+ if (!props.editing) {
+ return {props.value}
+ }
+
+ return (
+
+ {
+ setTimeout(() => {
+ el?.focus()
+ }, 0)
+ }}
+ />
+
+ {props.errorText != null && (
+
+ {props.errorText}
+
+ )}
+
+ )
+}
+
+interface ActionButtonsProps {
+ onEdit: () => void
+ onCancel: () => void
+ onCheck: () => void
+ editing: boolean
+}
+
+function ActionButtons(props: ActionButtonsProps): JSX.Element {
+ if (props.editing) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/lib/seam/devices/use-update-device-name.ts b/src/lib/seam/devices/use-update-device-name.ts
new file mode 100644
index 000000000..8c7d03475
--- /dev/null
+++ b/src/lib/seam/devices/use-update-device-name.ts
@@ -0,0 +1,93 @@
+import type {
+ DevicesGetParams,
+ DevicesUpdateBody,
+ SeamHttpApiError,
+} from '@seamapi/http/connect'
+import type { Device } from '@seamapi/types/connect'
+import {
+ useMutation,
+ type UseMutationResult,
+ useQueryClient,
+} from '@tanstack/react-query'
+
+import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js'
+
+export type UseUpdateDeviceNameParams = never
+
+export type UseUpdateDeviceNameData = undefined
+
+export type UseUpdateDeviceNameMutationVariables = Pick<
+ DevicesUpdateBody,
+ 'device_id' | 'name'
+>
+
+type MutationError = SeamHttpApiError
+
+export function useUpdateDeviceName(
+ params: DevicesGetParams
+): UseMutationResult<
+ UseUpdateDeviceNameData,
+ MutationError,
+ UseUpdateDeviceNameMutationVariables
+> {
+ const { client } = useSeamClient()
+ const queryClient = useQueryClient()
+
+ return useMutation<
+ UseUpdateDeviceNameData,
+ MutationError,
+ UseUpdateDeviceNameMutationVariables
+ >({
+ mutationFn: async (variables) => {
+ if (client === null) throw new NullSeamClientError()
+ await client.devices.update(variables)
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.setQueryData(
+ ['devices', 'get', params],
+ (device) => {
+ if (device == null) {
+ return
+ }
+
+ return getUpdatedDevice(
+ device,
+ variables.name ?? device.properties.name
+ )
+ }
+ )
+
+ queryClient.setQueryData(
+ ['devices', 'list', { device_id: variables.device_id }],
+ (devices): Device[] => {
+ if (devices == null) {
+ return []
+ }
+
+ return devices.map((device) => {
+ if (device.device_id === variables.device_id) {
+ return getUpdatedDevice(
+ device,
+ variables.name ?? device.properties.name
+ )
+ }
+
+ return device
+ })
+ }
+ )
+ },
+ })
+}
+
+const getUpdatedDevice = (device: Device, name: string): Device => {
+ const { properties } = device
+
+ return {
+ ...device,
+ properties: {
+ ...properties,
+ name,
+ },
+ }
+}
diff --git a/src/lib/ui/thermostat/ThermostatCard.tsx b/src/lib/ui/thermostat/ThermostatCard.tsx
index 0376d785b..a03f4ce3b 100644
--- a/src/lib/ui/thermostat/ThermostatCard.tsx
+++ b/src/lib/ui/thermostat/ThermostatCard.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { FanIcon } from 'lib/icons/Fan.js'
import { OffIcon } from 'lib/icons/Off.js'
+import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js'
import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
@@ -10,17 +11,18 @@ import { Temperature } from 'lib/ui/thermostat/Temperature.js'
interface ThermostatCardProps {
device: ThermostatDevice
+ onEditName?: (newName: string) => void
}
-export function ThermostatCard({ device }: ThermostatCardProps): JSX.Element {
+export function ThermostatCard(props: ThermostatCardProps): JSX.Element {
return (
-
+
)
}
-function Content(props: { device: ThermostatDevice }): JSX.Element | null {
+function Content(props: ThermostatCardProps): JSX.Element | null {
const { device } = props
const [temperatureUnit, setTemperatureUnit] = useState<
@@ -50,9 +52,12 @@ function Content(props: { device: ThermostatDevice }): JSX.Element | null {
-
- {device.properties.name}
-
+