Refine inline device alias editing
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.17.2',
|
macClient: '0.17.3',
|
||||||
gatewayClient: '0.18.2',
|
gatewayClient: '0.18.3',
|
||||||
gatewayBackend: '0.18.0',
|
gatewayBackend: '0.18.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const previousScrollTop = useRef(0);
|
const previousScrollTop = useRef(0);
|
||||||
const movementAnimations = useRef(new Map());
|
const movementAnimations = useRef(new Map());
|
||||||
const previousTraffic = useRef(new Map());
|
const previousTraffic = useRef(new Map());
|
||||||
|
const aliasBaseline = useRef({ id: '', value: '' });
|
||||||
|
const aliasWidth = useRef('1px');
|
||||||
const copyTimer = useRef(null);
|
const copyTimer = useRef(null);
|
||||||
const trafficDeltaTimer = useRef(null);
|
const trafficDeltaTimer = useRef(null);
|
||||||
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
|
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
|
||||||
@@ -346,10 +348,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAlias(event, device) {
|
async function saveAlias(device) {
|
||||||
event.preventDefault();
|
const nextAlias = alias.trim();
|
||||||
if (!await updateDevice(device, { alias })) return;
|
if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) {
|
||||||
setEditingId('');
|
setEditingId((current) => current === device.id ? '' : current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await updateDevice(device, { alias: nextAlias })) return;
|
||||||
|
setEditingId((current) => current === device.id ? '' : current);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePolicy(device, mode) {
|
async function updatePolicy(device, mode) {
|
||||||
@@ -395,9 +401,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditing(device) {
|
function startEditing(device, width = 1) {
|
||||||
|
const value = device.alias || device.hostname || '';
|
||||||
|
aliasBaseline.current = { id: device.id, value };
|
||||||
|
aliasWidth.current = `${width}px`;
|
||||||
setEditingId(device.id);
|
setEditingId(device.id);
|
||||||
setAlias(device.alias || device.hostname || '');
|
setAlias(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -560,36 +569,38 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="client-device-main">
|
<div className="client-device-main">
|
||||||
|
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<form className="client-device-alias" onSubmit={(event) => saveAlias(event, device)}>
|
|
||||||
<input
|
<input
|
||||||
|
className="client-device-alias-input"
|
||||||
value={alias}
|
value={alias}
|
||||||
|
style={{ '--alias-width': aliasWidth.current }}
|
||||||
maxLength="64"
|
maxLength="64"
|
||||||
autoFocus
|
autoFocus
|
||||||
aria-label="Название устройства"
|
aria-label="Название устройства"
|
||||||
|
aria-busy={saving}
|
||||||
|
disabled={saving}
|
||||||
onChange={(event) => setAlias(event.target.value)}
|
onChange={(event) => setAlias(event.target.value)}
|
||||||
|
onBlur={() => saveAlias(device)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') event.currentTarget.blur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="submit" aria-label="Сохранить название" disabled={saving}>✓</button>
|
) : hasName && <button
|
||||||
<button type="button" aria-label="Отменить изменение" disabled={saving} onClick={() => setEditingId('')}>×</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}`}>
|
|
||||||
{hasName && <button
|
|
||||||
className="client-device-alias-trigger"
|
className="client-device-alias-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Изменить название ${title}`}
|
aria-label={`Изменить название ${title}`}
|
||||||
onClick={() => startEditing(device)}
|
onClick={(event) => startEditing(device, event.currentTarget.getBoundingClientRect().width)}
|
||||||
>{title}</button>}
|
>{title}</button>}
|
||||||
{hasName && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
|
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
|
||||||
{device.ip ? <button
|
{device.ip ? <button
|
||||||
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||||
onClick={() => copyDeviceIp(device)}
|
onClick={() => copyDeviceIp(device)}
|
||||||
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
|
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
||||||
</h3>
|
</h3>
|
||||||
{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
|
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
|
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -608,8 +619,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
<Tooltip>Изменить название</Tooltip>
|
<Tooltip>Изменить название</Tooltip>
|
||||||
</span>}
|
</span>}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
||||||
<time
|
<time
|
||||||
dateTime={device.lastSeenAt}
|
dateTime={device.lastSeenAt}
|
||||||
|
|||||||
+26
-32
@@ -908,8 +908,7 @@ p {
|
|||||||
|
|
||||||
.client-devices-error button,
|
.client-devices-error button,
|
||||||
.client-device-pin,
|
.client-device-pin,
|
||||||
.client-device-edit,
|
.client-device-edit {
|
||||||
.client-device-alias button {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -969,6 +968,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-alias-trigger,
|
.client-device-alias-trigger,
|
||||||
|
.client-device-alias-input,
|
||||||
.client-device-ip {
|
.client-device-ip {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -989,6 +989,29 @@ p {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-alias-input {
|
||||||
|
width: var(--alias-width);
|
||||||
|
min-width: 1px;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
caret-color: var(--client-accent);
|
||||||
|
font: 700 14px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
animation: client-device-alias-edit-in 360ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-device-alias-edit-in {
|
||||||
|
from {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: blur(2px);
|
||||||
|
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-alias-trigger:hover,
|
.client-device-alias-trigger:hover,
|
||||||
.client-device-alias-trigger:focus-visible {
|
.client-device-alias-trigger:focus-visible {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
@@ -1624,36 +1647,6 @@ p {
|
|||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-alias {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 32px 32px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-alias input {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 5px 0;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid var(--client-border);
|
|
||||||
outline: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-text);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-alias input:focus {
|
|
||||||
border-color: var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-alias button {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-devices-error button {
|
.client-devices-error button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -4921,6 +4914,7 @@ p {
|
|||||||
.client-device-policy,
|
.client-device-policy,
|
||||||
.client-device-policy svg,
|
.client-device-policy svg,
|
||||||
.client-device-alias-trigger,
|
.client-device-alias-trigger,
|
||||||
|
.client-device-alias-input,
|
||||||
.client-device-ip,
|
.client-device-ip,
|
||||||
.client-device-traffic-value > span,
|
.client-device-traffic-value > span,
|
||||||
.client-device-traffic-breakdown,
|
.client-device-traffic-breakdown,
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
||||||
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
|
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
|
||||||
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
|
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||||||
assert.match(panel, /\{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">/);
|
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
|
||||||
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}/);
|
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
|
||||||
|
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
|
||||||
|
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
|
||||||
|
assert.match(panel, /aliasWidth\.current = `\$\{width\}px`[\s\S]*startEditing\(device, event\.currentTarget\.getBoundingClientRect\(\)\.width\)/);
|
||||||
|
assert.match(panel, /nextAlias === aliasBaseline\.current\.value\.trim\(\)[\s\S]*setEditingId\(\(current\) => current === device\.id \? '' : current\)/);
|
||||||
|
assert.doesNotMatch(panel, /client-device-alias"|Сохранить название|Отменить изменение/);
|
||||||
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
|
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
|
||||||
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
|
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
|
||||||
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
|
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
|
||||||
@@ -112,6 +117,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
||||||
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
||||||
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
|
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
|
||||||
|
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*client-device-alias-edit-in 360ms/);
|
||||||
|
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
|
||||||
|
assert.doesNotMatch(styles, /\.client-device-alias \{/);
|
||||||
assert.match(styles, /\.client-device-ip \{[\s\S]*font: 600 12px\/1\.2/);
|
assert.match(styles, /\.client-device-ip \{[\s\S]*font: 600 12px\/1\.2/);
|
||||||
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
|
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
|
||||||
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
||||||
|
|||||||
Reference in New Issue
Block a user