diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg index bf4c34678..2964432ac 100644 --- a/assets/icons/edit.svg +++ b/assets/icons/edit.svg @@ -1,10 +1,3 @@ - - - - - - - - + diff --git a/src/lib/icons/Edit.tsx b/src/lib/icons/Edit.tsx index fb68adcd0..fb9dac369 100644 --- a/src/lib/icons/Edit.tsx +++ b/src/lib/icons/Edit.tsx @@ -13,25 +13,12 @@ export function EditIcon(props: SVGProps): JSX.Element { viewBox='0 0 24 24' {...props} > - - - - - - + ) } diff --git a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx index 29e4ef625..eb8d75b6a 100644 --- a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx @@ -6,6 +6,7 @@ import { LockDeviceDetails } from 'lib/seam/components/DeviceDetails/LockDeviceD import { NoiseSensorDeviceDetails } from 'lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.js' import { ThermostatDeviceDetails } from 'lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js' import { useDevice } from 'lib/seam/devices/use-device.js' +import { useUpdateDeviceName } from 'lib/seam/devices/use-update-device-name.js' import { isLockDevice } from 'lib/seam/locks/lock-device.js' import { isNoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js' import { isThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' @@ -16,7 +17,6 @@ export interface DeviceDetailsProps extends CommonProps { } export const NestedDeviceDetails = withRequiredCommonProps(DeviceDetails) - export interface NestedSpecificDeviceDetailsProps extends Required> { onBack: (() => void) | undefined @@ -42,6 +42,19 @@ export function DeviceDetails({ device_id: deviceId, }) + const { mutate: setDeviceName } = useUpdateDeviceName({ + device_id: deviceId, + }) + + const updateDeviceName = (newName: string): void => { + if (device != null) { + setDeviceName({ + device_id: device.device_id, + name: newName, + }) + } + } + if (device == null) { return null } @@ -60,15 +73,33 @@ export function DeviceDetails({ } if (isLockDevice(device)) { - return + return ( + + ) } if (isThermostatDevice(device)) { - return + return ( + + ) } if (isNoiseSensorDevice(device)) { - return + return ( + + ) } return null diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx index 1a775c043..c8ff12aa4 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx @@ -6,6 +6,7 @@ import { NestedAccessCodeTable } from 'lib/seam/components/AccessCodeTable/Acces 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 { deviceErrorFilter, deviceWarningFilter } from 'lib/seam/filters.js' import type { LockDevice } from 'lib/seam/locks/lock-device.js' import { useToggleLock } from 'lib/seam/locks/use-toggle-lock.js' @@ -19,6 +20,7 @@ import { useToggle } from 'lib/ui/use-toggle.js' interface LockDeviceDetailsProps extends NestedSpecificDeviceDetailsProps { device: LockDevice + onEditName?: (newName: string) => void | Promise } export function LockDeviceDetails({ @@ -33,6 +35,7 @@ export function LockDeviceDetails({ disableConnectedAccountInformation, onBack, className, + onEditName, }: LockDeviceDetailsProps): JSX.Element | null { const [accessCodesOpen, toggleAccessCodesOpen] = useToggle() const toggleLock = useToggleLock() @@ -95,7 +98,12 @@ export function LockDeviceDetails({
{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} -

+