Skip to content

Commit e9785da

Browse files
committed
feat: Add edit functionality to DeviceDetails
1 parent 102735e commit e9785da

File tree

9 files changed

+235
-42
lines changed

9 files changed

+235
-42
lines changed

assets/icons/edit.svg

Lines changed: 1 addition & 8 deletions
Loading

src/lib/icons/Edit.tsx

Lines changed: 6 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/seam/components/DeviceDetails/DeviceDetails.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface NestedSpecificDeviceDetailsProps
2121
extends Required<Omit<CommonProps, 'onBack' | 'className'>> {
2222
onBack: (() => void) | undefined
2323
className: string | undefined
24+
onEditName?: (newName: string) => void
2425
}
2526

2627
export function DeviceDetails({
@@ -60,15 +61,15 @@ export function DeviceDetails({
6061
}
6162

6263
if (isLockDevice(device)) {
63-
return <LockDeviceDetails device={device} {...props} />
64+
return <LockDeviceDetails device={device} onEditName={props.onEditName} {...props} />
6465
}
6566

6667
if (isThermostatDevice(device)) {
67-
return <ThermostatDeviceDetails device={device} {...props} />
68+
return <ThermostatDeviceDetails device={device} onEditName={props.onEditName} {...props} />
6869
}
6970

7071
if (isNoiseSensorDevice(device)) {
71-
return <NoiseSensorDeviceDetails device={device} {...props} />
72+
return <NoiseSensorDeviceDetails device={device} onEditName={props.onEditName} {...props} />
7273
}
7374

7475
return null

src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { NestedAccessCodeTable } from 'lib/seam/components/AccessCodeTable/Acces
66
import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js'
77
import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js'
88
import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js'
9+
import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
910
import { deviceErrorFilter, deviceWarningFilter } from 'lib/seam/filters.js'
1011
import type { LockDevice } from 'lib/seam/locks/lock-device.js'
1112
import { useToggleLock } from 'lib/seam/locks/use-toggle-lock.js'
@@ -19,6 +20,7 @@ import { useToggle } from 'lib/ui/use-toggle.js'
1920

2021
interface LockDeviceDetailsProps extends NestedSpecificDeviceDetailsProps {
2122
device: LockDevice
23+
onEditName?: (newName: string) => void
2224
}
2325

2426
export function LockDeviceDetails({
@@ -33,6 +35,7 @@ export function LockDeviceDetails({
3335
disableConnectedAccountInformation,
3436
onBack,
3537
className,
38+
onEditName
3639
}: LockDeviceDetailsProps): JSX.Element | null {
3740
const [accessCodesOpen, toggleAccessCodesOpen] = useToggle()
3841
const toggleLock = useToggleLock()
@@ -95,7 +98,12 @@ export function LockDeviceDetails({
9598
</div>
9699
<div className='seam-info'>
97100
<span className='seam-label'>{t.device}</span>
98-
<h4 className='seam-device-name'>{device.properties.name}</h4>
101+
<SeamEditableDeviceName
102+
tagName='h4'
103+
value={device.properties.name}
104+
className='seam-device-name'
105+
onEdit={onEditName}
106+
/>
99107
<div className='seam-properties'>
100108
<span className='seam-label'>{t.status}:</span>{' '}
101109
<OnlineStatus device={device} />

src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react'
44
import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js'
55
import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js'
66
import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js'
7+
import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
78
import type { NoiseSensorDevice } from 'lib/seam/noise-sensors/noise-sensor-device.js'
89
import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
910
import { NoiseLevelStatus } from 'lib/ui/device/NoiseLevelStatus.js'
@@ -15,9 +16,9 @@ import { TabSet } from 'lib/ui/TabSet.js'
1516

1617
type TabType = 'details' | 'activity'
1718

18-
interface NoiseSensorDeviceDetailsProps
19-
extends NestedSpecificDeviceDetailsProps {
20-
device: NoiseSensorDevice
19+
interface NoiseSensorDeviceDetailsProps extends NestedSpecificDeviceDetailsProps {
20+
device: NoiseSensorDevice,
21+
onEditName?: (newName: string) => void,
2122
}
2223

2324
export function NoiseSensorDeviceDetails({
@@ -26,6 +27,7 @@ export function NoiseSensorDeviceDetails({
2627
disableResourceIds,
2728
onBack,
2829
className,
30+
onEditName
2931
}: NoiseSensorDeviceDetailsProps): JSX.Element | null {
3032
const [tab, setTab] = useState<TabType>('details')
3133

@@ -45,7 +47,7 @@ export function NoiseSensorDeviceDetails({
4547
</div>
4648
<div className='seam-info'>
4749
<span className='seam-label'>{t.noiseSensor}</span>
48-
<h4 className='seam-device-name'>{device.properties.name}</h4>
50+
<SeamEditableDeviceName onEdit={onEditName} tagName='h4' value={device.properties.name} />
4951
<div className='seam-properties'>
5052
<span className='seam-label'>{t.status}:</span>{' '}
5153
<OnlineStatus device={device} />
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/* eslint-disable import/no-duplicates */
2+
3+
import classNames from "classnames"
4+
import type React from "react"
5+
import { type KeyboardEvent, useCallback, useState } from "react"
6+
7+
import { CheckIcon } from "lib/icons/Check.js"
8+
import { CloseIcon } from "lib/icons/Close.js"
9+
import { EditIcon } from "lib/icons/Edit.js"
10+
11+
export type SeamDeviceNameProps = {
12+
onEdit?: (newName: string) => void;
13+
editable?: boolean;
14+
tagName?: string;
15+
value: string;
16+
} & React.HTMLAttributes<HTMLElement>;
17+
18+
function IconButton(props: React.PropsWithChildren<React.HTMLAttributes<HTMLButtonElement>>): JSX.Element {
19+
return <button {...props} className={classNames("seam-editable-device-name-icon-button", props.className)}>
20+
{props.children}
21+
</button>
22+
}
23+
24+
function fixName(name: string): string {
25+
return name.replace(/\s+/g, " ").trim()
26+
}
27+
28+
function isValidName(name: string): { type: 'success' } | { type: 'error', message: string } {
29+
if (name.length < 2) {
30+
return {
31+
type: 'error',
32+
message: 'Name must be at least 2 characters long'
33+
}
34+
}
35+
36+
if (name.length > 64) {
37+
return {
38+
type: 'error',
39+
message: 'Name must be at most 64 characters long'
40+
}
41+
}
42+
43+
return {
44+
type: 'success',
45+
} as const
46+
}
47+
48+
export function SeamEditableDeviceName({ onEdit, editable = true, tagName, value, ...props }: SeamDeviceNameProps): JSX.Element {
49+
const [editing, setEditing] = useState(false);
50+
const [errorText, setErrorText] = useState<null | string>(null)
51+
const [currentValue, setCurrentValue] = useState(value);
52+
const Tag = (tagName ?? "span") as "div";
53+
54+
const handleCheck = useCallback(() => {
55+
const fixedName = fixName(currentValue);
56+
const valid = isValidName(fixedName);
57+
58+
if (valid.type === 'error') {
59+
setErrorText(valid.message);
60+
return;
61+
}
62+
63+
setEditing(false);
64+
setCurrentValue(fixedName)
65+
onEdit?.(fixedName);
66+
}, [currentValue, onEdit])
67+
68+
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
69+
setCurrentValue(event.target.value);
70+
setErrorText(null);
71+
}, [])
72+
73+
const handleCancel = useCallback(() => {
74+
setEditing(false);
75+
setCurrentValue(value);
76+
setErrorText(null);
77+
}, [value])
78+
79+
const handleInputKeydown = useCallback((e: KeyboardEvent<HTMLInputElement>): void => {
80+
if (e.repeat) return;
81+
82+
if (e.key === "Enter") {
83+
handleCheck();
84+
} else if (e.key === "Escape") {
85+
handleCancel();
86+
}
87+
}, [handleCheck, handleCancel]);
88+
89+
return (
90+
<Tag {...props} className={classNames("seam-editable-device-name", props.className)}>
91+
{editing
92+
? (
93+
<span className="seam-editable-device-name-input-wrapper">
94+
<input
95+
type="text"
96+
defaultValue={value}
97+
onChange={handleChange}
98+
onKeyDown={handleInputKeydown}
99+
ref={(el) => {
100+
setTimeout(() => {
101+
el?.focus();
102+
}, 0)
103+
}}
104+
/>
105+
106+
{errorText != null && <span className="seam-editable-device-name-input-error">{errorText}</span>}
107+
</span>
108+
) : (
109+
<span>{value}</span>
110+
)}
111+
112+
<span className="seam-editable-device-name-icon-wrapper">
113+
{editable ? (
114+
editing
115+
? (<>
116+
<IconButton onClick={handleCheck}>
117+
<CheckIcon width="1em" height="1em" viewBox="0 0 24 24" />
118+
</IconButton>
119+
<IconButton onClick={handleCancel}>
120+
<CloseIcon width="1em" height="1em" viewBox="0 0 24 24" />
121+
</IconButton>
122+
</>) : (
123+
<IconButton onClick={() => { setEditing(true) }}>
124+
<EditIcon width="1em" height="1em" viewBox="0 0 24 24" />
125+
</IconButton>
126+
)
127+
) : null}
128+
</span>
129+
</Tag>
130+
);
131+
132+
}

src/lib/ui/thermostat/ThermostatCard.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,26 @@ import { useState } from 'react'
33

44
import { FanIcon } from 'lib/icons/Fan.js'
55
import { OffIcon } from 'lib/icons/Off.js'
6+
import { SeamEditableDeviceName } from 'lib/seam/components/SeamEditableDeviceName/SeamEditableDeviceName.js'
67
import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js'
78
import { DeviceImage } from 'lib/ui/device/DeviceImage.js'
89
import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
910
import { Temperature } from 'lib/ui/thermostat/Temperature.js'
1011

1112
interface ThermostatCardProps {
1213
device: ThermostatDevice
14+
onEditName?: (newName: string) => void
1315
}
1416

15-
export function ThermostatCard({ device }: ThermostatCardProps): JSX.Element {
17+
export function ThermostatCard(props: ThermostatCardProps): JSX.Element {
1618
return (
1719
<div className='seam-thermostat-card'>
18-
<Content device={device} />
20+
<Content device={props.device} onEditName={props.onEditName} />
1921
</div>
2022
)
2123
}
2224

23-
function Content(props: { device: ThermostatDevice }): JSX.Element | null {
25+
function Content(props: ThermostatCardProps): JSX.Element | null {
2426
const { device } = props
2527

2628
const [temperatureUnit, setTemperatureUnit] = useState<
@@ -50,9 +52,12 @@ function Content(props: { device: ThermostatDevice }): JSX.Element | null {
5052
</div>
5153
<div className='seam-thermostat-card-details'>
5254
<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+
/>
5661
<button
5762
onClick={toggleTemperatureUnit}
5863
className='seam-thermostat-temperature-toggle'

src/styles/_main.scss

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
@use './time-zone-picker';
3030
@use './tab-set';
3131
@use './noise-sensor';
32+
@use './seam-editable-device-name';
3233

3334
.seam-components {
3435
// Reset
@@ -54,6 +55,7 @@
5455
@include switch.all;
5556
@include time-zone-picker.all;
5657
@include tab-set.all;
58+
@include seam-editable-device-name.all;
5759

5860
// Components
5961
@include device-details.all;
@@ -66,4 +68,4 @@
6668
@include thermostat.all;
6769
@include seam-table.all;
6870
@include noise-sensor.all;
69-
}
71+
}

0 commit comments

Comments
 (0)