@seamapi/react 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/dist/elements.js +4652 -4471
  3. package/dist/elements.js.map +1 -1
  4. package/dist/index.css +49 -0
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.min.css +1 -1
  7. package/dist/index.min.css.map +1 -1
  8. package/lib/icons/Edit.js +2 -4
  9. package/lib/icons/Edit.js.map +1 -1
  10. package/lib/seam/components/DeviceDetails/DeviceDetails.js +15 -3
  11. package/lib/seam/components/DeviceDetails/DeviceDetails.js.map +1 -1
  12. package/lib/seam/components/DeviceDetails/LockDeviceDetails.d.ts +2 -1
  13. package/lib/seam/components/DeviceDetails/LockDeviceDetails.js +3 -2
  14. package/lib/seam/components/DeviceDetails/LockDeviceDetails.js.map +1 -1
  15. package/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.d.ts +2 -1
  16. package/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.js +3 -2
  17. package/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.js.map +1 -1
  18. package/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.d.ts +2 -1
  19. package/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js +2 -2
  20. package/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js.map +1 -1
  21. package/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.d.ts +8 -0
  22. package/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js +85 -0
  23. package/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js.map +1 -0
  24. package/lib/seam/devices/use-update-device-name.d.ts +8 -0
  25. package/lib/seam/devices/use-update-device-name.js +43 -0
  26. package/lib/seam/devices/use-update-device-name.js.map +1 -0
  27. package/lib/ui/thermostat/ThermostatCard.d.ts +2 -1
  28. package/lib/ui/thermostat/ThermostatCard.js +4 -3
  29. package/lib/ui/thermostat/ThermostatCard.js.map +1 -1
  30. package/lib/version.d.ts +1 -1
  31. package/lib/version.js +1 -1
  32. package/package.json +1 -1
  33. package/src/lib/icons/Edit.tsx +6 -19
  34. package/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx +35 -4
  35. package/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +9 -1
  36. package/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx +8 -1
  37. package/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +3 -1
  38. package/src/lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.tsx +208 -0
  39. package/src/lib/seam/devices/use-update-device-name.ts +93 -0
  40. package/src/lib/ui/thermostat/ThermostatCard.tsx +11 -6
  41. package/src/lib/version.ts +1 -1
  42. package/src/styles/_main.scss +2 -0
  43. package/src/styles/_seam-editable-device-name.scss +62 -0
@@ -4,6 +4,7 @@ import { useState } from 'react'
4
4
  import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js'
5
5
  import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js'
6
6
  import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js'
7
+ import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
7
8
  import type { NoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js'
8
9
  import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
9
10
  import { NoiseLevelStatus } from 'lib/ui/device/NoiseLevelStatus.js'
@@ -18,6 +19,7 @@ type TabType = 'details' | 'activity'
18
19
  interface NoiseSensorDeviceDetailsProps
19
20
  extends NestedSpecificDeviceDetailsProps {
20
21
  device: NoiseSensorDevice
22
+ onEditName?: (newName: string) => void | Promise<void>
21
23
  }
22
24
 
23
25
  export function NoiseSensorDeviceDetails({
@@ -26,6 +28,7 @@ export function NoiseSensorDeviceDetails({
26
28
  disableResourceIds,
27
29
  onBack,
28
30
  className,
31
+ onEditName,
29
32
  }: NoiseSensorDeviceDetailsProps): JSX.Element | null {
30
33
  const [tab, setTab] = useState<TabType>('details')
31
34
 
@@ -45,7 +48,11 @@ export function NoiseSensorDeviceDetails({
45
48
  </div>
46
49
  <div className='seam-info'>
47
50
  <span className='seam-label'>{t.noiseSensor}</span>
48
- <h4 className='seam-device-name'>{device.properties.name}</h4>
51
+ <SeamEditableDeviceName
52
+ onEdit={onEditName}
53
+ tagName='h4'
54
+ value={device.properties.name}
55
+ />
49
56
  <div className='seam-properties'>
50
57
  <span className='seam-label'>{t.status}:</span>{' '}
51
58
  <OnlineStatus device={device} />
@@ -29,6 +29,7 @@ import { ThermostatCard } from 'lib/ui/thermostat/ThermostatCard.js'
29
29
  interface ThermostatDeviceDetailsProps
30
30
  extends NestedSpecificDeviceDetailsProps {
31
31
  device: ThermostatDevice
32
+ onEditName?: (newName: string) => void | Promise<void>
32
33
  }
33
34
 
34
35
  export function ThermostatDeviceDetails({
@@ -37,6 +38,7 @@ export function ThermostatDeviceDetails({
37
38
  disableConnectedAccountInformation,
38
39
  onBack,
39
40
  className,
41
+ onEditName,
40
42
  }: ThermostatDeviceDetailsProps): JSX.Element | null {
41
43
  if (device == null) {
42
44
  return null
@@ -47,7 +49,7 @@ export function ThermostatDeviceDetails({
47
49
  <ContentHeader title={t.thermostat} onBack={onBack} />
48
50
 
49
51
  <div className='seam-body'>
50
- <ThermostatCard device={device} />
52
+ <ThermostatCard device={device} onEditName={onEditName} />
51
53
 
52
54
  <div className='seam-thermostat-device-details'>
53
55
  <DetailSectionGroup>
@@ -0,0 +1,208 @@
1
+ import classNames from 'classnames'
2
+ import {
3
+ type ChangeEvent,
4
+ type HTMLAttributes,
5
+ type KeyboardEvent,
6
+ type PropsWithChildren,
7
+ useCallback,
8
+ useState,
9
+ } from 'react'
10
+
11
+ import { CheckIcon } from 'lib/icons/Check.js'
12
+ import { CloseIcon } from 'lib/icons/Close.js'
13
+ import { EditIcon } from 'lib/icons/Edit.js'
14
+
15
+ export type SeamDeviceNameProps = {
16
+ onEdit?: (newName: string) => void
17
+ editable?: boolean
18
+ tagName?: string
19
+ value: string
20
+ } & HTMLAttributes<HTMLElement>
21
+
22
+ function IconButton(
23
+ props: PropsWithChildren<HTMLAttributes<HTMLButtonElement>>
24
+ ): JSX.Element {
25
+ return (
26
+ <button
27
+ {...props}
28
+ className={classNames(
29
+ 'seam-editable-device-name-icon-button',
30
+ props.className
31
+ )}
32
+ >
33
+ {props.children}
34
+ </button>
35
+ )
36
+ }
37
+
38
+ const fixName = (name: string): string => {
39
+ return name.replace(/\s+/g, ' ').trim()
40
+ }
41
+
42
+ type Result = { type: 'success' } | { type: 'error'; message: string }
43
+
44
+ const isValidName = (name: string): Result => {
45
+ if (name.length < 2) {
46
+ return {
47
+ type: 'error',
48
+ message: 'Name must be at least 2 characters long',
49
+ }
50
+ }
51
+
52
+ if (name.length > 64) {
53
+ return {
54
+ type: 'error',
55
+ message: 'Name must be at most 64 characters long',
56
+ }
57
+ }
58
+
59
+ return {
60
+ type: 'success',
61
+ } as const
62
+ }
63
+
64
+ export function SeamEditableDeviceName({
65
+ onEdit,
66
+ editable = true,
67
+ tagName,
68
+ value,
69
+ ...props
70
+ }: SeamDeviceNameProps): JSX.Element {
71
+ const [editing, setEditing] = useState(false)
72
+ const [errorText, setErrorText] = useState<null | string>(null)
73
+ const [currentValue, setCurrentValue] = useState(value)
74
+ const Tag = (tagName ?? 'span') as 'div'
75
+
76
+ const handleCheck = useCallback(() => {
77
+ const fixedName = fixName(currentValue)
78
+ const valid = isValidName(fixedName)
79
+
80
+ if (valid.type === 'error') {
81
+ setErrorText(valid.message)
82
+ return
83
+ }
84
+
85
+ setEditing(false)
86
+ setCurrentValue(fixedName)
87
+ onEdit?.(fixedName)
88
+ }, [currentValue, onEdit])
89
+
90
+ const handleChange = useCallback(
91
+ (event: ChangeEvent<HTMLInputElement>): void => {
92
+ setCurrentValue(event.target.value)
93
+ setErrorText(null)
94
+ },
95
+ []
96
+ )
97
+
98
+ const handleCancel = useCallback(() => {
99
+ setEditing(false)
100
+ setCurrentValue(value)
101
+ setErrorText(null)
102
+ }, [value])
103
+
104
+ const handleInputKeydown = useCallback(
105
+ (e: KeyboardEvent<HTMLInputElement>): void => {
106
+ if (e.repeat) return
107
+
108
+ if (e.key === 'Enter') {
109
+ handleCheck()
110
+ } else if (e.key === 'Escape') {
111
+ handleCancel()
112
+ }
113
+ },
114
+ [handleCheck, handleCancel]
115
+ )
116
+
117
+ return (
118
+ <Tag
119
+ {...props}
120
+ className={classNames('seam-editable-device-name', props.className)}
121
+ >
122
+ <NameView
123
+ editing={editing}
124
+ value={currentValue}
125
+ onChange={handleChange}
126
+ onKeyDown={handleInputKeydown}
127
+ errorText={errorText}
128
+ />
129
+
130
+ {editable && (
131
+ <span className='seam-editable-device-name-icon-wrapper'>
132
+ <ActionButtons
133
+ editing={editing}
134
+ onEdit={() => {
135
+ setEditing(true)
136
+ }}
137
+ onCancel={handleCancel}
138
+ onCheck={handleCheck}
139
+ />
140
+ </span>
141
+ )}
142
+ </Tag>
143
+ )
144
+ }
145
+
146
+ interface NameViewProps {
147
+ editing: boolean
148
+ value: string
149
+ onChange: (event: ChangeEvent<HTMLInputElement>) => void
150
+ onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
151
+ errorText?: string | null
152
+ }
153
+
154
+ function NameView(props: NameViewProps): JSX.Element {
155
+ if (!props.editing) {
156
+ return <span>{props.value}</span>
157
+ }
158
+
159
+ return (
160
+ <span className='seam-editable-device-name-input-wrapper'>
161
+ <input
162
+ type='text'
163
+ defaultValue={props.value}
164
+ onChange={props.onChange}
165
+ onKeyDown={props.onKeyDown}
166
+ ref={(el) => {
167
+ setTimeout(() => {
168
+ el?.focus()
169
+ }, 0)
170
+ }}
171
+ />
172
+
173
+ {props.errorText != null && (
174
+ <span className='seam-editable-device-name-input-error'>
175
+ {props.errorText}
176
+ </span>
177
+ )}
178
+ </span>
179
+ )
180
+ }
181
+
182
+ interface ActionButtonsProps {
183
+ onEdit: () => void
184
+ onCancel: () => void
185
+ onCheck: () => void
186
+ editing: boolean
187
+ }
188
+
189
+ function ActionButtons(props: ActionButtonsProps): JSX.Element {
190
+ if (props.editing) {
191
+ return (
192
+ <>
193
+ <IconButton onClick={props.onCheck}>
194
+ <CheckIcon width='1em' height='1em' viewBox='0 0 24 24' />
195
+ </IconButton>
196
+ <IconButton onClick={props.onCancel}>
197
+ <CloseIcon width='1em' height='1em' viewBox='0 0 24 24' />
198
+ </IconButton>
199
+ </>
200
+ )
201
+ }
202
+
203
+ return (
204
+ <IconButton onClick={props.onEdit}>
205
+ <EditIcon width='1em' height='1em' viewBox='0 0 24 24' />
206
+ </IconButton>
207
+ )
208
+ }
@@ -0,0 +1,93 @@
1
+ import type {
2
+ DevicesGetParams,
3
+ DevicesUpdateBody,
4
+ SeamHttpApiError,
5
+ } from '@seamapi/http/connect'
6
+ import type { Device } from '@seamapi/types/connect'
7
+ import {
8
+ useMutation,
9
+ type UseMutationResult,
10
+ useQueryClient,
11
+ } from '@tanstack/react-query'
12
+
13
+ import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js'
14
+
15
+ export type UseUpdateDeviceNameParams = never
16
+
17
+ export type UseUpdateDeviceNameData = undefined
18
+
19
+ export type UseUpdateDeviceNameMutationVariables = Pick<
20
+ DevicesUpdateBody,
21
+ 'device_id' | 'name'
22
+ >
23
+
24
+ type MutationError = SeamHttpApiError
25
+
26
+ export function useUpdateDeviceName(
27
+ params: DevicesGetParams
28
+ ): UseMutationResult<
29
+ UseUpdateDeviceNameData,
30
+ MutationError,
31
+ UseUpdateDeviceNameMutationVariables
32
+ > {
33
+ const { client } = useSeamClient()
34
+ const queryClient = useQueryClient()
35
+
36
+ return useMutation<
37
+ UseUpdateDeviceNameData,
38
+ MutationError,
39
+ UseUpdateDeviceNameMutationVariables
40
+ >({
41
+ mutationFn: async (variables) => {
42
+ if (client === null) throw new NullSeamClientError()
43
+ await client.devices.update(variables)
44
+ },
45
+ onSuccess: (_data, variables) => {
46
+ queryClient.setQueryData<Device | null>(
47
+ ['devices', 'get', params],
48
+ (device) => {
49
+ if (device == null) {
50
+ return
51
+ }
52
+
53
+ return getUpdatedDevice(
54
+ device,
55
+ variables.name ?? device.properties.name
56
+ )
57
+ }
58
+ )
59
+
60
+ queryClient.setQueryData<Device[]>(
61
+ ['devices', 'list', { device_id: variables.device_id }],
62
+ (devices): Device[] => {
63
+ if (devices == null) {
64
+ return []
65
+ }
66
+
67
+ return devices.map((device) => {
68
+ if (device.device_id === variables.device_id) {
69
+ return getUpdatedDevice(
70
+ device,
71
+ variables.name ?? device.properties.name
72
+ )
73
+ }
74
+
75
+ return device
76
+ })
77
+ }
78
+ )
79
+ },
80
+ })
81
+ }
82
+
83
+ const getUpdatedDevice = (device: Device, name: string): Device => {
84
+ const { properties } = device
85
+
86
+ return {
87
+ ...device,
88
+ properties: {
89
+ ...properties,
90
+ name,
91
+ },
92
+ }
93
+ }
@@ -3,6 +3,7 @@ import { useState } from 'react'
3
3
 
4
4
  import { FanIcon } from 'lib/icons/Fan.js'
5
5
  import { OffIcon } from 'lib/icons/Off.js'
6
+ import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
6
7
  import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js'
7
8
  import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
8
9
  import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
@@ -10,17 +11,18 @@ import { Temperature } from 'lib/ui/thermostat/Temperature.js'
10
11
 
11
12
  interface ThermostatCardProps {
12
13
  device: ThermostatDevice
14
+ onEditName?: (newName: string) => void
13
15
  }
14
16
 
15
- export function ThermostatCard({ device }: ThermostatCardProps): JSX.Element {
17
+ export function ThermostatCard(props: ThermostatCardProps): JSX.Element {
16
18
  return (
17
19
  <div className='seam-thermostat-card'>
18
- <Content device={device} />
20
+ <Content device={props.device} onEditName={props.onEditName} />
19
21
  </div>
20
22
  )
21
23
  }
22
24
 
23
- function Content(props: { device: ThermostatDevice }): JSX.Element | null {
25
+ function Content(props: ThermostatCardProps): JSX.Element | null {
24
26
  const { device } = props
25
27
 
26
28
  const [temperatureUnit, setTemperatureUnit] = useState<
@@ -50,9 +52,12 @@ function Content(props: { device: ThermostatDevice }): JSX.Element | null {
50
52
  </div>
51
53
  <div className='seam-thermostat-card-details'>
52
54
  <div className='seam-thermostat-heading-wrap'>
53
- <h4 className='seam-thermostat-card-heading'>
54
- {device.properties.name}
55
- </h4>
55
+ <SeamEditableDeviceName
56
+ value={device.properties.name}
57
+ tagName='h4'
58
+ className='seam-thermostat-card-heading'
59
+ onEdit={props.onEditName}
60
+ />
56
61
  <button
57
62
  onClick={toggleTemperatureUnit}
58
63
  className='seam-thermostat-temperature-toggle'
@@ -1,3 +1,3 @@
1
- const seamapiReactVersion = '4.2.0'
1
+ const seamapiReactVersion = '4.3.0'
2
2
 
3
3
  export default seamapiReactVersion
@@ -29,6 +29,7 @@
29
29
  @use './time-zone-picker';
30
30
  @use './tab-set';
31
31
  @use './noise-sensor';
32
+ @use './seam-editable-device-name';
32
33
 
33
34
  .seam-components {
34
35
  // Reset
@@ -54,6 +55,7 @@
54
55
  @include switch.all;
55
56
  @include time-zone-picker.all;
56
57
  @include tab-set.all;
58
+ @include seam-editable-device-name.all;
57
59
 
58
60
  // Components
59
61
  @include device-details.all;
@@ -0,0 +1,62 @@
1
+ @use './colors';
2
+
3
+ @mixin all {
4
+ .seam-editable-device-name {
5
+ input {
6
+ border: none;
7
+ background-color: transparent;
8
+ font-size: inherit;
9
+ font-weight: inherit;
10
+ letter-spacing: inherit;
11
+ line-height: inherit;
12
+ font-family: inherit;
13
+ margin: 0;
14
+ padding: 0;
15
+ border-bottom: 1px dashed currentcolor;
16
+ outline: none;
17
+ }
18
+
19
+ &-input-wrapper {
20
+ display: inline-flex;
21
+ flex-flow: column nowrap;
22
+ }
23
+
24
+ &-input-error {
25
+ color: colors.$status-red;
26
+ font-size: 0.8em;
27
+ margin-top: 5px;
28
+ }
29
+
30
+ &-icon-wrapper {
31
+ margin-left: 10px;
32
+ display: inline-flex;
33
+ flex-flow: row nowrap;
34
+ gap: 5px;
35
+ }
36
+
37
+ &-icon-button {
38
+ display: inline-flex;
39
+ padding: 0;
40
+ margin: 0;
41
+ background: none;
42
+ border: none;
43
+ width: fit-content;
44
+ cursor: pointer;
45
+ transform-origin: center;
46
+ border-radius: 2px;
47
+
48
+ path {
49
+ fill: currentcolor;
50
+ }
51
+
52
+ &:hover {
53
+ background-color: currentcolor;
54
+ box-shadow: 0 0 0 2px currentcolor;
55
+
56
+ path {
57
+ fill: white;
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }