Improve device tag editing and version clients
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.33.0',
|
||||
gatewayClient: '0.34.0',
|
||||
macClient: '0.33.1',
|
||||
gatewayClient: '0.34.1',
|
||||
gatewayBackend: '0.34.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
|
||||
|
||||
interface TagPopoverState {
|
||||
deviceId: string;
|
||||
title: string;
|
||||
baseline: string[];
|
||||
draft: string[];
|
||||
catalogKey: string;
|
||||
@@ -137,6 +136,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const [tagPopover, setTagPopover] = useState<TagPopoverState | null>(null);
|
||||
const [tagPopoverClosing, setTagPopoverClosing] = useState(false);
|
||||
const [tagAnnouncement, setTagAnnouncement] = useState('');
|
||||
const [tagMutationCycle, setTagMutationCycle] = useState(0);
|
||||
const [addingTag, setAddingTag] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [editingTagId, setEditingTagId] = useState('');
|
||||
const [editingTagName, setEditingTagName] = useState('');
|
||||
@@ -152,6 +153,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const copyAttempts = useRef(new Map<string, object>());
|
||||
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tagPopoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const tagMutationPending = useRef(false);
|
||||
const railModeAnimation = useRef<Animation | null>(null);
|
||||
const railModeRequest = useRef(0);
|
||||
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
|
||||
@@ -162,6 +164,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const popoverRef = useRef<HTMLElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const managerInputRef = useRef<HTMLInputElement>(null);
|
||||
const addTagButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const tagTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const restoreTagTriggerFocus = useRef(true);
|
||||
const editingTagBaseline = useRef('');
|
||||
@@ -212,7 +215,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
useEffect(() => {
|
||||
const knownTagIds = new Set(tags.map(({ id }) => id));
|
||||
setSelectedTagIds((current) => current.filter((id) => knownTagIds.has(id)));
|
||||
if (!tagPopover) return;
|
||||
if (!tagPopover || tagMutationPending.current || tagSavingId === tagPopover.deviceId) return;
|
||||
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
|
||||
const baselineChanged = !device || device.tagIds.length !== tagPopover.baseline.length
|
||||
|| device.tagIds.some((id, index) => id !== tagPopover.baseline[index]);
|
||||
@@ -220,19 +223,21 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
if (!baselineChanged && !catalogChanged) return;
|
||||
closeTagPopover();
|
||||
setTagAnnouncement('Список тегов изменился. Откройте теги устройства снова.');
|
||||
}, [snapshot?.tags, snapshot?.devices]);
|
||||
}, [snapshot?.tags, snapshot?.devices, tagSavingId, tagMutationCycle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
restoreTagTriggerFocus.current = false;
|
||||
setMobileRailOpen(false);
|
||||
setRailMode('filters');
|
||||
setAddingTag(false);
|
||||
setEditingTagId('');
|
||||
closeTagPopover(true, false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (railMode !== 'manager' || tagPopover || (compactRail && !mobileRailOpen)) return undefined;
|
||||
const frame = requestAnimationFrame(() => managerInputRef.current?.focus());
|
||||
const frame = requestAnimationFrame(() => (managerInputRef.current || addTagButtonRef.current)?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [compactRail, mobileRailOpen, railMode, tagPopover]);
|
||||
|
||||
@@ -285,7 +290,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
if (layoutRef.current) layoutRef.current.inert = true;
|
||||
if (closeRef.current) closeRef.current.inert = true;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const assigned = popoverRef.current?.querySelector<HTMLInputElement>('input:checked');
|
||||
const assigned = popoverRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
||||
const first = popoverRef.current?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(assigned || first)?.focus();
|
||||
});
|
||||
@@ -549,6 +554,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
|
||||
function closeTagPopover(immediate = false, restoreFocus = true) {
|
||||
if (!tagPopover) return;
|
||||
if (tagMutationPending.current && !immediate) return;
|
||||
restoreTagTriggerFocus.current = restoreFocus;
|
||||
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
|
||||
if (immediate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
@@ -602,7 +608,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
});
|
||||
}
|
||||
|
||||
function openTagPopover(device: Device, title: string, anchor: DOMRect) {
|
||||
function openTagPopover(device: Device, anchor: DOMRect) {
|
||||
if (tagMutationPending.current) return;
|
||||
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
|
||||
clearTagError();
|
||||
setTagAnnouncement('');
|
||||
@@ -610,7 +617,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
setTagPopoverClosing(false);
|
||||
setTagPopover({
|
||||
deviceId: device.id,
|
||||
title,
|
||||
baseline: [...device.tagIds],
|
||||
draft: [...device.tagIds],
|
||||
catalogKey: tags.map(({ id, name }) => `${id}:${name}`).join('|'),
|
||||
@@ -618,11 +624,34 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDeviceTags() {
|
||||
if (!tagPopover) return;
|
||||
async function toggleDeviceTag(tagId: string) {
|
||||
if (!tagPopover || tagMutationPending.current) return;
|
||||
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
|
||||
if (!device) return;
|
||||
if (await updateDeviceTags(device, tagPopover.draft, tagPopover.baseline)) closeTagPopover();
|
||||
const baseline = [...tagPopover.baseline];
|
||||
const selected = tagPopover.draft.includes(tagId);
|
||||
const selectedIds = selected
|
||||
? tagPopover.draft.filter((id) => id !== tagId)
|
||||
: [...tagPopover.draft, tagId];
|
||||
const next = tags.filter(({ id }) => selectedIds.includes(id)).map(({ id }) => id);
|
||||
if (next.length > 8) return;
|
||||
clearTagError();
|
||||
setTagPopover((current) => current && ({ ...current, draft: next }));
|
||||
tagMutationPending.current = true;
|
||||
try {
|
||||
if (await updateDeviceTags(device, next, baseline)) {
|
||||
setTagPopover((current) => current?.deviceId === device.id
|
||||
? { ...current, baseline: next, draft: next }
|
||||
: current);
|
||||
} else {
|
||||
setTagPopover((current) => current?.deviceId === device.id
|
||||
? { ...current, draft: baseline }
|
||||
: current);
|
||||
}
|
||||
} finally {
|
||||
tagMutationPending.current = false;
|
||||
setTagMutationCycle((cycle) => cycle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function showTagManager() {
|
||||
@@ -640,7 +669,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
if (!newTagName.trim() || !await createTag(newTagName)) return;
|
||||
setTagErrorCopy('');
|
||||
setNewTagName('');
|
||||
requestAnimationFrame(() => managerInputRef.current?.focus());
|
||||
setAddingTag(false);
|
||||
requestAnimationFrame(() => addTagButtonRef.current?.focus());
|
||||
}
|
||||
|
||||
async function submitTagRename(tag: DeviceTag) {
|
||||
@@ -766,23 +796,12 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
onClick={() => {
|
||||
clearTagError();
|
||||
setTagErrorCopy('');
|
||||
setAddingTag(false);
|
||||
setEditingTagId('');
|
||||
void changeRailMode('filters');
|
||||
}}
|
||||
>Назад</button>
|
||||
<h3>Управление тегами</h3>
|
||||
<form className="client-devices-tag-create" onSubmit={submitNewTag}>
|
||||
<input
|
||||
ref={managerInputRef}
|
||||
value={newTagName}
|
||||
maxLength={24}
|
||||
placeholder="Название тега"
|
||||
aria-label="Название нового тега"
|
||||
disabled={tags.length >= 32 || tagSavingId === 'create'}
|
||||
onChange={(event) => setNewTagName(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Создать</button>
|
||||
</form>
|
||||
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
|
||||
<div className="client-devices-tag-manager-list">
|
||||
{tags.map((tag) => <div key={tag.id} className="client-devices-tag-manager-row" data-tag-tone={tagTone(tag.id)}>
|
||||
{editingTagId === tag.id ? <form onSubmit={(event) => {
|
||||
@@ -802,18 +821,27 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</form> : <>
|
||||
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
|
||||
<button
|
||||
className={`client-devices-tag-edit${pencilAnimationId === `tag:${tag.id}` ? ' is-writing' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Переименовать тег ${tag.name}`}
|
||||
disabled={tagSavingId === tag.id}
|
||||
onPointerEnter={() => setPencilAnimationId(`tag:${tag.id}`)}
|
||||
onFocus={() => setPencilAnimationId(`tag:${tag.id}`)}
|
||||
onClick={() => {
|
||||
clearTagError();
|
||||
setTagErrorCopy('');
|
||||
setAddingTag(false);
|
||||
setEditingTagId(tag.id);
|
||||
setEditingTagName(tag.name);
|
||||
editingTagBaseline.current = tag.name;
|
||||
}}
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
|
||||
><svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => setPencilAnimationId((id) => id === `tag:${tag.id}` ? '' : id)}
|
||||
><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
|
||||
<button
|
||||
className="client-row-delete client-devices-tag-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить тег ${tag.name}`}
|
||||
disabled={tagSavingId === tag.id}
|
||||
@@ -822,11 +850,46 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
setTagErrorCopy('');
|
||||
setDeletingTagId(tag.id);
|
||||
}}
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5" /></svg></button>
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-row-delete-lid" d="M8 7V5h8v2m-11 0h14" />
|
||||
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
|
||||
</svg></button>
|
||||
</>}
|
||||
</div>)}
|
||||
{!tags.length && <p>Создайте первый тег, чтобы распределить устройства.</p>}
|
||||
{!tags.length && !addingTag && <p>Тегов пока нет.</p>}
|
||||
</div>
|
||||
{addingTag ? <form className="client-devices-tag-create" onSubmit={submitNewTag}>
|
||||
<input
|
||||
ref={managerInputRef}
|
||||
value={newTagName}
|
||||
maxLength={24}
|
||||
placeholder="Название тега"
|
||||
aria-label="Название нового тега"
|
||||
disabled={tags.length >= 32 || tagSavingId === 'create'}
|
||||
onChange={(event) => setNewTagName(event.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Добавить</button>
|
||||
<button type="button" disabled={tagSavingId === 'create'} onClick={() => {
|
||||
clearTagError();
|
||||
setTagErrorCopy('');
|
||||
setNewTagName('');
|
||||
setAddingTag(false);
|
||||
requestAnimationFrame(() => addTagButtonRef.current?.focus());
|
||||
}}>Отмена</button>
|
||||
</form> : <button
|
||||
ref={addTagButtonRef}
|
||||
className="client-row-add client-devices-tag-add"
|
||||
type="button"
|
||||
disabled={tags.length >= 32}
|
||||
onClick={() => {
|
||||
clearTagError();
|
||||
setTagErrorCopy('');
|
||||
setEditingTagId('');
|
||||
setAddingTag(true);
|
||||
requestAnimationFrame(() => managerInputRef.current?.focus());
|
||||
}}
|
||||
>+ Добавить тег</button>}
|
||||
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1093,8 +1156,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
data-tag-tone={firstTag ? tagTone(firstTag.id) : undefined}
|
||||
type="button"
|
||||
aria-label={`Изменить теги устройства ${title}`}
|
||||
disabled={tagSavingId === device.id}
|
||||
onClick={(event) => openTagPopover(device, title, event.currentTarget.getBoundingClientRect())}
|
||||
disabled={Boolean(tagSavingId)}
|
||||
onClick={(event) => openTagPopover(device, event.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
{firstTag ? <><span>{firstTag.name}</span>{deviceTags.length > 1 && <b>+{deviceTags.length - 1}</b>}</> : '+ тег'}
|
||||
</button>}
|
||||
@@ -1234,44 +1297,30 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
className="client-device-tag-popover"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Теги устройства ${tagPopover.title}`}
|
||||
aria-label="Теги"
|
||||
aria-busy={tagSavingId === tagPopover.deviceId}
|
||||
style={{
|
||||
top: `${Math.max(12, Math.min(tagPopover.anchor.bottom + 8, window.innerHeight - 360))}px`,
|
||||
left: `${Math.max(12, Math.min(tagPopover.anchor.left, window.innerWidth - 292))}px`,
|
||||
}}
|
||||
>
|
||||
<h3>Теги устройства {tagPopover.title}</h3>
|
||||
{tags.length ? <div className="client-device-tag-popover-options">
|
||||
{tags.map((tag) => {
|
||||
const checked = tagPopover.draft.includes(tag.id);
|
||||
return <label key={tag.id} data-tag-tone={tagTone(tag.id)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={tagSavingId === tagPopover.deviceId || (!checked && tagPopover.draft.length >= 8)}
|
||||
onChange={() => setTagPopover((current) => current && ({
|
||||
...current,
|
||||
draft: checked
|
||||
? current.draft.filter((id) => id !== tag.id)
|
||||
: [...current.draft, tag.id],
|
||||
}))}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
</label>;
|
||||
const selected = tagPopover.draft.includes(tag.id);
|
||||
return <button
|
||||
key={tag.id}
|
||||
data-tag-tone={tagTone(tag.id)}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-disabled={Boolean(tagSavingId) || (!selected && tagPopover.draft.length >= 8)}
|
||||
onClick={() => {
|
||||
if (tagSavingId || (!selected && tagPopover.draft.length >= 8)) return;
|
||||
void toggleDeviceTag(tag.id);
|
||||
}}
|
||||
>{tag.name}</button>;
|
||||
})}
|
||||
</div> : <p>Тегов пока нет.</p>}
|
||||
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось сохранить теги.</p>}
|
||||
<div className="client-device-tag-popover-actions">
|
||||
{!tags.length && <button type="button" onClick={showTagManager}>Создать тег</button>}
|
||||
<button type="button" disabled={tagSavingId === tagPopover.deviceId} onClick={() => closeTagPopover()}>Отмена</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={tagSavingId === tagPopover.deviceId || (tagPopover.baseline.length === tagPopover.draft.length
|
||||
&& tagPopover.baseline.every((id, index) => id === tagPopover.draft[index]))}
|
||||
onClick={saveDeviceTags}
|
||||
>Сохранить</button>
|
||||
</div>
|
||||
</div> : <button className="client-row-add" type="button" onClick={showTagManager}>+ Создать тег</button>}
|
||||
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось изменить тег.</p>}
|
||||
</section>
|
||||
</div>,
|
||||
document.querySelector('.app.client-app') || document.body,
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
text-transform: var(--type-label-transform);
|
||||
}
|
||||
|
||||
.client-devices-search input,
|
||||
.client-devices-tag-create input,
|
||||
.client-devices-tag-manager-row input {
|
||||
.client-devices-search input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
@@ -50,12 +48,36 @@
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-devices-search input:focus,
|
||||
.client-devices-tag-create input:focus,
|
||||
.client-devices-tag-manager-row input:focus {
|
||||
.client-devices-search input:focus {
|
||||
border: 1px solid var(--client-accent);
|
||||
}
|
||||
|
||||
.client-devices-tag-create input,
|
||||
.client-devices-tag-manager-row input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
caret-color: var(--client-accent);
|
||||
color: var(--client-text);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
transition: color 180ms ease, border-bottom-color 180ms ease, box-shadow 220ms ease, text-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.client-devices-tag-create input:focus-visible,
|
||||
.client-devices-tag-manager-row input:focus-visible {
|
||||
border-bottom-color: var(--client-accent);
|
||||
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
|
||||
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 32%, transparent);
|
||||
}
|
||||
|
||||
.client-devices-filter-group,
|
||||
.client-devices-tag-filters {
|
||||
display: grid;
|
||||
@@ -125,7 +147,8 @@
|
||||
.client-devices-manage-tags,
|
||||
.client-devices-manager-back,
|
||||
.client-devices-tag-create button,
|
||||
.client-devices-tag-manager-row button,
|
||||
.client-devices-tag-manager-row form button,
|
||||
.client-devices-tag-edit,
|
||||
.client-device-tag-popover button,
|
||||
.client-devices-filter-summary button,
|
||||
.client-devices-filter-empty button {
|
||||
@@ -152,7 +175,8 @@
|
||||
|
||||
.client-devices-tag-create {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -184,6 +208,9 @@
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.client-devices-tag-manager-row > button:not(.client-row-delete) {
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
@@ -197,6 +224,15 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.client-devices-tag-edit:hover:not(:disabled),
|
||||
.client-devices-tag-edit:focus-visible {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-devices-tag-edit.is-writing svg {
|
||||
animation: client-device-pencil-write 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-devices-tag-manager-row form {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
@@ -1703,14 +1739,7 @@
|
||||
to { opacity: 0; transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.client-device-tag-popover h3 {
|
||||
margin: 0;
|
||||
font: var(--type-section-title);
|
||||
letter-spacing: var(--type-section-title-tracking);
|
||||
text-transform: var(--type-section-title-transform);
|
||||
}
|
||||
|
||||
.client-device-tag-popover > p {
|
||||
.client-device-tag-popover > p:not(.client-devices-tag-error) {
|
||||
margin: 0;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
@@ -1719,30 +1748,42 @@
|
||||
}
|
||||
|
||||
.client-device-tag-popover-options {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.client-device-tag-popover-options label {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--tag-tone, var(--client-text));
|
||||
.client-device-tag-popover-options button {
|
||||
min-height: 28px;
|
||||
padding: 4px 7px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease, background 180ms ease, filter 260ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.client-device-tag-popover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
.client-device-tag-popover-options button:hover:not(:disabled),
|
||||
.client-device-tag-popover-options button:focus-visible,
|
||||
.client-device-tag-popover-options button[aria-pressed="true"] {
|
||||
background: color-mix(in oklch, var(--tag-tone) 10%, transparent);
|
||||
color: var(--tag-tone);
|
||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--tag-tone) 32%, transparent));
|
||||
}
|
||||
|
||||
.client-device-tag-popover-actions button:last-child {
|
||||
color: var(--client-text);
|
||||
.client-device-tag-popover-options button:disabled,
|
||||
.client-device-tag-popover-options button[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.client-device-tag-popover-options button[aria-pressed="true"]:disabled,
|
||||
.client-device-tag-popover-options button[aria-pressed="true"][aria-disabled="true"] {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -93,7 +93,12 @@
|
||||
.client-devices-rail,
|
||||
.client-devices-rail-backdrop,
|
||||
.client-devices-rail-view,
|
||||
.client-devices-tag-create input,
|
||||
.client-devices-tag-manager-row input,
|
||||
.client-devices-tag-edit,
|
||||
.client-devices-tag-edit svg,
|
||||
.client-device-tag-popover,
|
||||
.client-device-tag-popover-options button,
|
||||
.client-text-morph-value,
|
||||
.client-proxy-label > span {
|
||||
transition: none;
|
||||
|
||||
@@ -243,16 +243,22 @@ test('device tags keep filtering, assignment and management accessible at every
|
||||
assert.match(panel, /deviceFilterCounts\(allDevices, tags\)/);
|
||||
assert.match(panel, /По этим фильтрам устройств нет\.[\s\S]*Сбросить фильтры/);
|
||||
assert.match(panel, /deviceTags = tags\.filter[\s\S]*client-device-tag-trigger[\s\S]*\+\{deviceTags\.length - 1\}[\s\S]*'\+ тег'/);
|
||||
assert.match(panel, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*Теги устройства \{tagPopover\.title\}/);
|
||||
assert.match(panel, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*aria-label="Теги"/);
|
||||
assert.match(panel, /client-device-tag-popover-options[\s\S]*aria-pressed=\{selected\}[\s\S]*toggleDeviceTag\(tag\.id\)/);
|
||||
assert.match(panel, /tagMutationPending = useRef\(false\)[\s\S]*if \(!tagPopover \|\| tagMutationPending\.current \|\| tagSavingId === tagPopover\.deviceId\) return[\s\S]*if \(tagMutationPending\.current && !immediate\) return[\s\S]*if \(!tagPopover \|\| tagMutationPending\.current\) return/);
|
||||
assert.match(panel, /current\?\.deviceId === device\.id[\s\S]*baseline: next, draft: next[\s\S]*current\?\.deviceId === device\.id[\s\S]*draft: baseline[\s\S]*tagMutationPending\.current = false[\s\S]*setTagMutationCycle/);
|
||||
assert.match(panel, /aria-disabled=\{Boolean\(tagSavingId\)[\s\S]*if \(tagSavingId \|\| \(!selected && tagPopover\.draft\.length >= 8\)\) return/);
|
||||
assert.doesNotMatch(panel, /client-device-tag-popover-actions|type="checkbox"|<h3>Теги устройства/);
|
||||
assert.match(panel, /contentRef\.current\.inert = true[\s\S]*layoutRef\.current\.inert = true/);
|
||||
assert.match(panel, /FOCUSABLE[\s\S]*event\.key === 'Escape'[\s\S]*document\.activeElement === first/);
|
||||
assert.match(panel, /catalogChanged[\s\S]*Список тегов изменился\. Откройте теги устройства снова\./);
|
||||
assert.match(panel, /Теги доступны после обновления Gateway\./);
|
||||
assert.match(panel, /Управление тегами[\s\S]*Переименовать тег[\s\S]*Удалить тег/);
|
||||
assert.match(panel, /placeholder="Найти устройство"[\s\S]*Результатов: \{devices\.length\}/);
|
||||
assert.match(panel, />Назад<\/button>[\s\S]*Не удалось сохранить теги\./);
|
||||
assert.match(panel, />Назад<\/button>[\s\S]*\+ Добавить тег/);
|
||||
assert.match(panel, /Не удалось изменить тег\./);
|
||||
assert.match(panel, /restoreTagTriggerFocus[\s\S]*searchRef\.current,[\s\S]*filterButtonRef\.current,[\s\S]*candidate\?\.isConnected && !candidate\.closest\('\[inert\]'\)/);
|
||||
assert.match(panel, /updateDeviceTags\(device, tagPopover\.draft, tagPopover\.baseline\)/);
|
||||
assert.match(panel, /setTagPopover\(\(current\) => current && \(\{ \.\.\.current, draft: next \}\)\)[\s\S]*updateDeviceTags\(device, next, baseline\)[\s\S]*baseline: next, draft: next[\s\S]*draft: baseline/);
|
||||
assert.match(feature, /updateDeviceTags\(device: Device, tagIds: string\[\], baselineTagIds: string\[\]\)[\s\S]*sameStringList\(currentDevice\.tagIds, baselineTagIds\)[\s\S]*sameStringList\(latestDevice\.tagIds, baselineTagIds\)/);
|
||||
assert.match(feature, /renameTag\(tag: DeviceTag, name: string, baselineName: string\)[\s\S]*snapshot\.tags\.find[\s\S]*name !== baselineName[\s\S]*latestTag\.name !== baselineName/);
|
||||
assert.match(panel, /Тег больше не будет доступен для назначения\. Устройства и маршруты не изменятся\./);
|
||||
@@ -265,9 +271,15 @@ test('device tags keep filtering, assignment and management accessible at every
|
||||
assert.match(styles, /client-device-tag-popover-in 160ms ease[\s\S]*translateY\(-6px\)[\s\S]*client-device-tag-popover-out 160ms ease forwards/);
|
||||
assert.match(styles, /client-devices-rail-backdrop \{[\s\S]*opacity: 0[\s\S]*opacity 260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)[\s\S]*client-devices-rail-backdrop\.is-open[\s\S]*opacity: 1/);
|
||||
assert.match(styles, /client-device-tag-trigger \{[\s\S]*height: 14px[\s\S]*flex: 0 0 auto/);
|
||||
assert.match(styles, /\.client-devices-tag-create input,[\s\S]*\.client-devices-tag-manager-row input \{[\s\S]*border: 0;[\s\S]*border-bottom: 1px solid var\(--client-border\);[\s\S]*border-radius: 0;[\s\S]*background: transparent/);
|
||||
assert.match(panel, /client-devices-tag-edit[\s\S]*is-writing[\s\S]*client-row-delete client-devices-tag-delete[\s\S]*client-row-delete-lid/);
|
||||
assert.match(styles, /\.client-devices-tag-manager-row form button,\n\.client-devices-tag-edit,\n\.client-device-tag-popover button,/);
|
||||
assert.match(styles, /\.client-devices-manage-tags,[\s\S]*\.client-devices-filter-empty button \{[\s\S]*border: 0;[\s\S]*background: transparent/);
|
||||
assert.match(styles, /\.client-devices-tag-edit\.is-writing svg \{[\s\S]*client-device-pencil-write 620ms/);
|
||||
assert.match(styles, /\.client-device-tag-popover-options button \{[\s\S]*color: var\(--client-muted\)[\s\S]*button\[aria-pressed="true"\] \{[\s\S]*color: var\(--tag-tone\)[\s\S]*drop-shadow/);
|
||||
assert.match(styles, /@media \(hover: hover\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 0/);
|
||||
assert.match(styles, /@media \(hover: none\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 1/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce[\s\S]*client-devices-rail[\s\S]*client-devices-rail-view[\s\S]*client-device-tag-popover/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce[\s\S]*client-devices-rail[\s\S]*client-devices-rail-view[\s\S]*client-devices-tag-create input[\s\S]*client-devices-tag-edit[\s\S]*client-device-tag-popover/);
|
||||
});
|
||||
|
||||
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||||
|
||||
@@ -39,26 +39,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1093,
|
||||
cascadeEdges: 1120,
|
||||
customProperties: 115,
|
||||
declarations: 4433,
|
||||
declarations: 4455,
|
||||
important: 0,
|
||||
keyframes: 52,
|
||||
media: 19,
|
||||
rules: 1188,
|
||||
variableReferences: 1130,
|
||||
rules: 1193,
|
||||
variableReferences: 1138,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: '6676dd45ca19923561ef30b2057a9952a795fd142d83b612aaba70132378e91d',
|
||||
cascadeEdges: 'd8eeaf20637638f97dd826ae7486469367fb7f5c7e2d32a6fb1d64647e8b52e0',
|
||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||
declarations: 'fcecaa0fb211a14826577bf9629e261b0b4b63b4601febebc43e8d757f6a07f1',
|
||||
declarations: '89b645d94c44f0dcfea1a7c5f653a8124f0c87057a9898437419ddce813271d2',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
|
||||
ruleDeclarationSequences: '231bd9880d19c8d29e6d9538ccb934e135bd58b33155175e317b743916d93ef7',
|
||||
selectors: '159b53f5cdd6c4aa6d892b9ed314d9a100345290d4c95e31a4c6be98b609c8b6',
|
||||
variableReferences: 'd8f4abdb1ea6e9077283f342efef0ea920068165d0fc2ac4e7a9a22e16b31461',
|
||||
witnesses: 'a84c2ee1c55cb68873f62f230aeec39f88093bcbb50493aaf47d852b13e6985c',
|
||||
ruleDeclarationSequences: '12a5a2deec8a8521f49551d9a29c1950a01bce225f1b387def47304fcfe1c960',
|
||||
selectors: 'fa7cc612da0fc1e3804a884032e9ab99e0bb0776b29a9e40d44a2891de198067',
|
||||
variableReferences: '1cbaf891cd2df7c91003d8879a205075211678ad40a817b746c4dde54830ad6d',
|
||||
witnesses: 'bae0329346060aeeef91dee449ccee7187e68501c0b5b082026312e7c21a4798',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,7 +211,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 1209);
|
||||
assert.equal(witnesses.length, 1198);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -408,8 +408,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-fn-ai4xB.css']);
|
||||
assert.deepEqual(assets, ['index-C2yOT86L.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 168021);
|
||||
assert.equal(sha256(built), 'a160cdc915a4a8e0b95e360ed721e6037cbeda28df4e0815ab6be43f1004176d');
|
||||
assert.equal(built.byteLength, 169625);
|
||||
assert.equal(sha256(built), 'a9bb2c83ab62798e762f4338be1869a6d29366903f83f90d4abcaa2855e44d8e');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user