From 4ab7e32ad3365cadb7ca5012561a5a8c94e183a2 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Tue, 7 Oct 2025 09:44:14 +0300 Subject: [PATCH 1/9] fix: device lock status disappearing when unlocked --- src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx index cfc741a6c..646222c18 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx @@ -163,7 +163,7 @@ export function LockDeviceDetails({
- {device.properties.locked && device.properties.online && ( + {device.properties.online && (
{t.lockStatus} From e616d3f5ad84a99dc1617a79331f3ebbbceef1f2 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Wed, 8 Oct 2025 13:32:40 +0300 Subject: [PATCH 2/9] create separate LockDeviceToggleLockButton --- .../DeviceDetails/LockDeviceDetails.tsx | 36 ++++------------ .../LockDeviceToggleLockButton.tsx | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 src/lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.tsx diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx index 646222c18..2f13b2d5a 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx @@ -7,11 +7,11 @@ 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 { LockDeviceToggleLockButton } from 'lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.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' import { Alerts } from 'lib/ui/Alert/Alerts.js' -import { Button } from 'lib/ui/Button.js' import { BatteryStatusIndicator } from 'lib/ui/device/BatteryStatusIndicator.js' import { DeviceImage } from 'lib/ui/device/DeviceImage.js' import { EditableDeviceName } from 'lib/ui/device/EditableDeviceName.js' @@ -59,9 +59,6 @@ export function LockDeviceDetails({ }, }) - const lockStatus = device.properties.locked ? t.locked : t.unlocked - const toggleLockLabel = device.properties.locked ? t.unlock : t.lock - const accessCodeCount = accessCodes?.length if (accessCodes == null) { @@ -164,25 +161,13 @@ export function LockDeviceDetails({
{device.properties.online && ( -
-
- {t.lockStatus} - {lockStatus} -
-
- {!disableLockUnlock && - device.capabilities_supported.includes('lock') && ( - - )} -
-
+ { + toggleLock.mutate(device) + }} + device={device} + disableLockUnlock={disableLockUnlock} + /> )} void + disableLockUnlock: boolean +} + +export function LockDeviceToggleLockButton({ + device, + onToggle, + disableLockUnlock, +}: LockDeviceToggleLockButtonProps): JSX.Element { + const lockStatus = device.properties.locked ? t.locked : t.unlocked + const toggleLockLabel = device.properties.locked ? t.unlock : t.lock + + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ {!disableLockUnlock && + device.capabilities_supported.includes('lock') && ( + + )} +
+
+ ) +} + +const t = { + unlock: 'Unlock', + lock: 'Lock', + locked: 'Locked', + unlocked: 'Unlocked', + lockStatus: 'Lock status', +} From f865f2fc5db858b91510ce86b3b3374969fc3c64 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Wed, 8 Oct 2025 13:58:33 +0300 Subject: [PATCH 3/9] conditionally render lock/unlock --- .../AccessCodeDetails/AccessCodeDevice.tsx | 2 +- .../DeviceDetails/DeviceDetails.tsx | 2 +- .../DeviceDetails/LockDeviceDetails.tsx | 32 +--- .../DeviceDetails/LockDeviceLockButtons.tsx | 165 ++++++++++++++++++ .../LockDeviceToggleLockButton.tsx | 42 ----- .../components/DeviceTable/DeviceTable.tsx | 2 +- .../{lock-device.ts => is-lock-device.ts} | 0 src/lib/seam/locks/use-lock.ts | 101 +++++++++++ src/lib/seam/locks/use-unlock.ts | 104 +++++++++++ src/lib/ui/device/LockStatus.tsx | 2 +- 10 files changed, 382 insertions(+), 70 deletions(-) create mode 100644 src/lib/seam/components/DeviceDetails/LockDeviceLockButtons.tsx delete mode 100644 src/lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.tsx rename src/lib/seam/locks/{lock-device.ts => is-lock-device.ts} (100%) create mode 100644 src/lib/seam/locks/use-lock.ts create mode 100644 src/lib/seam/locks/use-unlock.ts diff --git a/src/lib/seam/components/AccessCodeDetails/AccessCodeDevice.tsx b/src/lib/seam/components/AccessCodeDetails/AccessCodeDevice.tsx index db2330025..cbc9ee052 100644 --- a/src/lib/seam/components/AccessCodeDetails/AccessCodeDevice.tsx +++ b/src/lib/seam/components/AccessCodeDetails/AccessCodeDevice.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useDevice } from 'lib/seam/devices/use-device.js' -import { isLockDevice, type LockDevice } from 'lib/seam/locks/lock-device.js' +import { isLockDevice, type LockDevice } from 'lib/seam/locks/is-lock-device.js' import { useToggleLock } from 'lib/seam/locks/use-toggle-lock.js' import { Button } from 'lib/ui/Button.js' import { DeviceImage } from 'lib/ui/device/DeviceImage.js' diff --git a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx index eb8d75b6a..0b62276c7 100644 --- a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx @@ -7,7 +7,7 @@ import { NoiseSensorDeviceDetails } from 'lib/seam/components/DeviceDetails/Nois 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 { isLockDevice } from 'lib/seam/locks/is-lock-device.js' import { isNoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js' import { isThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' import { useComponentTelemetry } from 'lib/telemetry/index.js' diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx index 2f13b2d5a..2c9e5eb23 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx @@ -7,10 +7,9 @@ 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 { LockDeviceToggleLockButton } from 'lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.js' +import { LockDeviceLockButtons } from 'lib/seam/components/DeviceDetails/LockDeviceLockButtons.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' +import type { LockDevice } from 'lib/seam/locks/is-lock-device.js' import { Alerts } from 'lib/ui/Alert/Alerts.js' import { BatteryStatusIndicator } from 'lib/ui/device/BatteryStatusIndicator.js' import { DeviceImage } from 'lib/ui/device/DeviceImage.js' @@ -48,17 +47,6 @@ export function LockDeviceDetails({ const [snackbarVariant, setSnackbarVariant] = useState('success') - const toggleLock = useToggleLock({ - onSuccess: () => { - setSnackbarVisible(true) - setSnackbarVariant('success') - }, - onError: () => { - setSnackbarVisible(true) - setSnackbarVariant('error') - }, - }) - const accessCodeCount = accessCodes?.length if (accessCodes == null) { @@ -160,16 +148,12 @@ export function LockDeviceDetails({
- {device.properties.online && ( - { - toggleLock.mutate(device) - }} - device={device} - disableLockUnlock={disableLockUnlock} - /> - )} - + void + setSnackbarVariant: (variant: SnackbarVariant) => void +} + +export function LockDeviceLockButtons({ + device, + setSnackbarVariant, + setSnackbarVisible, + disableLockUnlock, +}: LockDeviceLockButtonsProps): JSX.Element | null { + const lockStatus = device.properties.locked ? t.locked : t.unlocked + const toggleLockLabel = device.properties.locked ? t.unlock : t.lock + + const toggleLock = useToggleLock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + const lock = useLock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + const unlock = useUnlock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + if (!device.properties.online) { + return null + } + + if (disableLockUnlock) { + return null + } + + if ( + device.can_remotely_lock === true && + device.can_remotely_unlock === true + ) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + if (device.can_remotely_lock === true) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + if (device.can_remotely_unlock === true) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ + +
+
+ ) +} + +const t = { + unlock: 'Unlock', + lock: 'Lock', + locked: 'Locked', + unlocked: 'Unlocked', + lockStatus: 'Lock status', +} diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.tsx deleted file mode 100644 index aa504ea40..000000000 --- a/src/lib/seam/components/DeviceDetails/LockDeviceToggleLockButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { LockDevice } from 'lib/seam/locks/lock-device.js' -import { Button } from 'lib/ui/Button.js' - -interface LockDeviceToggleLockButtonProps { - device: LockDevice - onToggle: () => void - disableLockUnlock: boolean -} - -export function LockDeviceToggleLockButton({ - device, - onToggle, - disableLockUnlock, -}: LockDeviceToggleLockButtonProps): JSX.Element { - const lockStatus = device.properties.locked ? t.locked : t.unlocked - const toggleLockLabel = device.properties.locked ? t.unlock : t.lock - - return ( -
-
- {t.lockStatus} - {lockStatus} -
-
- {!disableLockUnlock && - device.capabilities_supported.includes('lock') && ( - - )} -
-
- ) -} - -const t = { - unlock: 'Unlock', - lock: 'Lock', - locked: 'Locked', - unlocked: 'Unlocked', - lockStatus: 'Lock status', -} diff --git a/src/lib/seam/components/DeviceTable/DeviceTable.tsx b/src/lib/seam/components/DeviceTable/DeviceTable.tsx index 356e18568..b43a14aca 100644 --- a/src/lib/seam/components/DeviceTable/DeviceTable.tsx +++ b/src/lib/seam/components/DeviceTable/DeviceTable.tsx @@ -15,7 +15,7 @@ import { } from 'lib/seam/components/DeviceTable/DeviceHealthBar.js' import { DeviceRow } from 'lib/seam/components/DeviceTable/DeviceRow.js' import { useDevices } from 'lib/seam/devices/use-devices.js' -import { isLockDevice } from 'lib/seam/locks/lock-device.js' +import { isLockDevice } from 'lib/seam/locks/is-lock-device.js' import { isNoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js' import { isThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' import { useComponentTelemetry } from 'lib/telemetry/index.js' diff --git a/src/lib/seam/locks/lock-device.ts b/src/lib/seam/locks/is-lock-device.ts similarity index 100% rename from src/lib/seam/locks/lock-device.ts rename to src/lib/seam/locks/is-lock-device.ts diff --git a/src/lib/seam/locks/use-lock.ts b/src/lib/seam/locks/use-lock.ts new file mode 100644 index 000000000..311716763 --- /dev/null +++ b/src/lib/seam/locks/use-lock.ts @@ -0,0 +1,101 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, +} from '@seamapi/http/connect' +import { NullSeamClientError, useSeamClient } from '@seamapi/react-query' +import type { ActionAttempt, Device } from '@seamapi/types/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +export type UseLockData = undefined + +export type UseLockMutationVariables = Pick & { + properties: Required> +} + +type LockActionAttempt = Extract + +type MutationError = + | SeamHttpApiError + | SeamActionAttemptFailedError + | SeamActionAttemptTimeoutError + +interface UseLockParams { + onError?: () => void + onSuccess?: () => void +} + +export function useLock( + params: UseLockParams = {} +): UseMutationResult { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables) => { + const { + device_id: deviceId, + properties: { locked }, + } = variables + if (client === null) throw new NullSeamClientError() + if (locked == null) return + await client.locks.lockDoor({ device_id: deviceId }) + }, + onMutate: (variables) => { + queryClient.setQueryData(['devices', 'list', {}], (devices) => { + if (devices == null) { + return devices + } + + return devices.map((device) => { + if ( + device.device_id !== variables.device_id || + device.properties.locked == null + ) { + return device + } + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + }) + }) + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device?.properties.locked == null) return device + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + } + ) + }, + onError: async (_error, variables) => { + params.onError?.() + + await queryClient.invalidateQueries({ + queryKey: ['devices', 'list'], + }) + await queryClient.invalidateQueries({ + queryKey: ['devices', 'get', { device_id: variables.device_id }], + }) + }, + onSuccess() { + params.onSuccess?.() + }, + }) +} diff --git a/src/lib/seam/locks/use-unlock.ts b/src/lib/seam/locks/use-unlock.ts new file mode 100644 index 000000000..755b9b2f2 --- /dev/null +++ b/src/lib/seam/locks/use-unlock.ts @@ -0,0 +1,104 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, +} from '@seamapi/http/connect' +import { NullSeamClientError, useSeamClient } from '@seamapi/react-query' +import type { ActionAttempt, Device } from '@seamapi/types/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +export type UseUnlockData = undefined + +export type UseUnlockMutationVariables = Pick & { + properties: Required> +} + +type UnlockActionAttempt = Extract< + ActionAttempt, + { action_type: 'UNLOCK_DOOR' } +> + +type MutationError = + | SeamHttpApiError + | SeamActionAttemptFailedError + | SeamActionAttemptTimeoutError + +interface UseUnlockParams { + onError?: () => void + onSuccess?: () => void +} + +export function useUnlock( + params: UseUnlockParams = {} +): UseMutationResult { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables) => { + const { + device_id: deviceId, + properties: { locked }, + } = variables + if (client === null) throw new NullSeamClientError() + if (locked == null) return + await client.locks.unlockDoor({ device_id: deviceId }) + }, + onMutate: (variables) => { + queryClient.setQueryData(['devices', 'list', {}], (devices) => { + if (devices == null) { + return devices + } + + return devices.map((device) => { + if ( + device.device_id !== variables.device_id || + device.properties.locked == null + ) { + return device + } + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + }) + }) + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device?.properties.locked == null) return device + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + } + ) + }, + onError: async (_error, variables) => { + params.onError?.() + + await queryClient.invalidateQueries({ + queryKey: ['devices', 'list'], + }) + await queryClient.invalidateQueries({ + queryKey: ['devices', 'get', { device_id: variables.device_id }], + }) + }, + onSuccess() { + params.onSuccess?.() + }, + }) +} diff --git a/src/lib/ui/device/LockStatus.tsx b/src/lib/ui/device/LockStatus.tsx index 703457eee..84b89557a 100644 --- a/src/lib/ui/device/LockStatus.tsx +++ b/src/lib/ui/device/LockStatus.tsx @@ -2,7 +2,7 @@ import type { Device } from '@seamapi/types/connect' import { LockLockedIcon } from 'lib/icons/LockLocked.js' import { LockUnlockedIcon } from 'lib/icons/LockUnlocked.js' -import { isLockDevice } from 'lib/seam/locks/lock-device.js' +import { isLockDevice } from 'lib/seam/locks/is-lock-device.js' interface LockStatusProps { device: Device From b796131955fd041bffa998e88575c803d6a528a0 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Wed, 8 Oct 2025 14:02:05 +0300 Subject: [PATCH 4/9] add unknown status --- .../seam/components/DeviceDetails/LockDeviceLockButtons.tsx | 3 ++- src/styles/_device-details.scss | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceLockButtons.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceLockButtons.tsx index bcf3c5ff1..0bbb7be48 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceLockButtons.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceLockButtons.tsx @@ -132,7 +132,7 @@ export function LockDeviceLockButtons({
{t.lockStatus} - {lockStatus} + {t.statusUnknown}
- -
-
- ) + return null } const t = { diff --git a/src/lib/seam/components/DeviceTable/DeviceTable.tsx b/src/lib/seam/components/DeviceTable/DeviceTable.tsx index b43a14aca..356e18568 100644 --- a/src/lib/seam/components/DeviceTable/DeviceTable.tsx +++ b/src/lib/seam/components/DeviceTable/DeviceTable.tsx @@ -15,7 +15,7 @@ import { } from 'lib/seam/components/DeviceTable/DeviceHealthBar.js' import { DeviceRow } from 'lib/seam/components/DeviceTable/DeviceRow.js' import { useDevices } from 'lib/seam/devices/use-devices.js' -import { isLockDevice } from 'lib/seam/locks/is-lock-device.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' import { useComponentTelemetry } from 'lib/telemetry/index.js' diff --git a/src/lib/ui/device/LockStatus.tsx b/src/lib/ui/device/LockStatus.tsx index 84b89557a..703457eee 100644 --- a/src/lib/ui/device/LockStatus.tsx +++ b/src/lib/ui/device/LockStatus.tsx @@ -2,7 +2,7 @@ import type { Device } from '@seamapi/types/connect' import { LockLockedIcon } from 'lib/icons/LockLocked.js' import { LockUnlockedIcon } from 'lib/icons/LockUnlocked.js' -import { isLockDevice } from 'lib/seam/locks/is-lock-device.js' +import { isLockDevice } from 'lib/seam/locks/lock-device.js' interface LockStatusProps { device: Device