перетягивание внутри drap-and-drop блоков и поправлена настройка панели кнопок с блоками

This commit is contained in:
2026-08-20 12:56:49 +03:00
parent 72d89ec02b
commit 484c2363c9
3 changed files with 210 additions and 20 deletions

View File

@@ -0,0 +1,51 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_player_selection_slots_can_be_reordered_inside_panel():
for token in (
"hockeyPlayerSelectionDrag",
"async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot)",
'application/x-ui-builder-hockey-player-selection-slot',
'event.dataTransfer.dropEffect = "move"',
"await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot)",
):
assert token in APP_JS
def test_swap_updates_both_slots_in_one_mapping_refresh():
start = APP_JS.index("async function hockeySwapPlayerSelectionSlots")
end = APP_JS.index("async function hockeyClearPlayerSelectionSlot", start)
block = APP_JS[start:end]
assert "...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer)" in block
assert "...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer)" in block
assert "await hockeySetMatchValues(patch, { refreshMapping: true });" in block
def test_filled_player_slots_expose_drag_handle_and_move_cursor():
assert 'draggable="true" title="Перетащите на другой слот' in APP_JS
assert "hockey-player-select-drag" in APP_JS
assert '.hockey-player-select-slot[draggable="true"] { cursor:grab; }' in STYLES
assert ".hockey-player-select-slot.is-dragging" in STYLES
def test_bottom_panel_settings_are_split_into_two_tabs():
for token in (
'data-prematch-editor-tab="buttons"',
'data-prematch-editor-tab="players"',
'Кнопки</button>',
'Блоки игроков <span>${draftPlayerPanels.length}</span>',
'activeEditorSection = section;',
):
assert token in APP_JS
assert ".prematch-editor-tabs" in STYLES
assert ".prematch-editor-tab.active" in STYLES
def test_player_panel_editor_only_renders_in_players_tab():
assert 'activeEditorSection === "buttons" ? `' in APP_JS
assert 'data-prematch-editor-section="players"' in APP_JS
assert 'player-selection-editor prematch-editor-section' in APP_JS

View File

@@ -122,6 +122,7 @@
hockeyTimerDirty: false, hockeyTimerDirty: false,
hockeyTimerRevision: 0, hockeyTimerRevision: 0,
hockeyDragPlayer: null, hockeyDragPlayer: null,
hockeyPlayerSelectionDrag: null,
hockeyDragInfraction: null, hockeyDragInfraction: null,
hockeyDragPreset: null, hockeyDragPreset: null,
editorAuthTimer: null, editorAuthTimer: null,
@@ -7648,6 +7649,7 @@ function openHockeyPrematchButtonsEditor() {
let dragButtonId = ""; let dragButtonId = "";
let dragSelectorId = ""; let dragSelectorId = "";
let activeEditorGroupId = String(state.quickPanelActiveTab || ""); let activeEditorGroupId = String(state.quickPanelActiveTab || "");
let activeEditorSection = "buttons";
if (activeEditorGroupId === "__ungrouped") activeEditorGroupId = ""; if (activeEditorGroupId === "__ungrouped") activeEditorGroupId = "";
if (activeEditorGroupId && !draftGroups.some((group) => group.id === activeEditorGroupId)) activeEditorGroupId = draftGroups[0]?.id || ""; if (activeEditorGroupId && !draftGroups.some((group) => group.id === activeEditorGroupId)) activeEditorGroupId = draftGroups[0]?.id || "";
const collapsedEditorGroups = new Set(); const collapsedEditorGroups = new Set();
@@ -7755,22 +7757,31 @@ function openHockeyPrematchButtonsEditor() {
draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels); draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels);
showSettingsModal("Нижняя панель · кнопки", ` showSettingsModal("Нижняя панель · кнопки", `
<div class="prematch-editor"> <div class="prematch-editor">
<div class="prematch-editor-head"> <nav class="prematch-editor-tabs" aria-label="Раздел настроек нижней панели">
<p>Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.</p> <button type="button" class="prematch-editor-tab ${activeEditorSection === "buttons" ? "active" : ""}" data-prematch-editor-tab="buttons">Кнопки</button>
<div class="prematch-editor-head-actions"> <button type="button" class="prematch-editor-tab ${activeEditorSection === "players" ? "active" : ""}" data-prematch-editor-tab="players">Блоки игроков <span>${draftPlayerPanels.length}</span></button>
<button type="button" class="btn" data-prematch-add-group>+ Вкладка</button> </nav>
<button type="button" class="btn" data-quick-selector-add>+ Переключатель</button> ${activeEditorSection === "buttons" ? `
<button type="button" class="btn btn-accent" data-prematch-add>+ Кнопка</button> <section class="prematch-editor-section" data-prematch-editor-section="buttons">
</div> <div class="prematch-editor-head">
</div> <p>Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.</p>
<div class="prematch-editor-list"> <div class="prematch-editor-head-actions">
${renderGroup(null, -1)} <button type="button" class="btn" data-prematch-add-group>+ Вкладка</button>
${draftGroups.map((group, index) => renderGroup(group, index)).join("")} <button type="button" class="btn" data-quick-selector-add>+ Переключатель</button>
</div> <button type="button" class="btn btn-accent" data-prematch-add>+ Кнопка</button>
<section class="player-selection-editor"> </div>
<header><div><span>DRAG & DROP ИГРОКОВ</span><strong>Блоки выбора игроков</strong><small>Название ID автоматически формирует идентификаторы Mapping: player_select.&lt;ID&gt;.player1_id и т. д.</small></div><button type="button" class="btn" data-player-panel-add>+ Блок игроков</button></header> </div>
<div class="player-selection-editor-list">${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `<div class="prematch-group-empty">Блоков игроков пока нет</div>`}</div> <div class="prematch-editor-list">
</section> ${renderGroup(null, -1)}
${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
</div>
</section>
` : `
<section class="player-selection-editor prematch-editor-section" data-prematch-editor-section="players">
<header><div><span>DRAG & DROP ИГРОКОВ</span><strong>Блоки выбора игроков</strong><small>Создание сравнений, «3 звезды» и одиночных выборов. ID автоматически формирует Mapping: player_select.&lt;ID&gt;.player1_id и т. д.</small></div><button type="button" class="btn" data-player-panel-add>+ Блок игроков</button></header>
<div class="player-selection-editor-list">${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `<div class="prematch-group-empty">Блоков игроков пока нет</div>`}</div>
</section>
`}
<div class="prematch-editor-actions"> <div class="prematch-editor-actions">
<button type="button" class="btn" data-prematch-cancel>Отмена</button> <button type="button" class="btn" data-prematch-cancel>Отмена</button>
<button type="button" class="btn btn-accent" data-prematch-save>Сохранить и опубликовать</button> <button type="button" class="btn btn-accent" data-prematch-save>Сохранить и опубликовать</button>
@@ -7778,6 +7789,13 @@ function openHockeyPrematchButtonsEditor() {
</div> </div>
`, { className: "modal-prematch-full" }); `, { className: "modal-prematch-full" });
document.querySelectorAll("[data-prematch-editor-tab]").forEach((button) => button.addEventListener("click", () => {
const section = String(button.dataset.prematchEditorTab || "buttons");
if (!["buttons", "players"].includes(section) || section === activeEditorSection) return;
activeEditorSection = section;
renderEditor();
}));
document.querySelector("[data-prematch-add-group]")?.addEventListener("click", () => { document.querySelector("[data-prematch-add-group]")?.addEventListener("click", () => {
if (draftGroups.length >= 24) return toast("Можно создать до 24 вкладок", true); if (draftGroups.length >= 24) return toast("Можно создать до 24 вкладок", true);
const newGroup = { id: `group_${draftGroups.length + 1}`, label: `Вкладка ${draftGroups.length + 1}`, sort_order: draftGroups.length * 10, enabled: true }; const newGroup = { id: `group_${draftGroups.length + 1}`, label: `Вкладка ${draftGroups.length + 1}`, sort_order: draftGroups.length * 10, enabled: true };
@@ -8363,7 +8381,7 @@ function hockeyPlayerSelectionTeamId(player) {
if (!player) return ""; if (!player) return "";
const side = player.side === "away" ? "away" : player.side === "home" ? "home" : ""; const side = player.side === "away" ? "away" : player.side === "home" ? "home" : "";
const raw = player.raw || {}; const raw = player.raw || {};
const direct = raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || ""; const direct = player.teamId || player.team_id || raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || "";
if (direct) return String(direct); if (direct) return String(direct);
if (!side) return ""; if (!side) return "";
const team = getByPath(state.data, `hockey.selected_game.${side}`) || {}; const team = getByPath(state.data, `hockey.selected_game.${side}`) || {};
@@ -8456,6 +8474,27 @@ async function hockeySetPlayerSelectionSlot(panel, slot, player) {
return true; return true;
} }
async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot) {
const sourceSlot = clamp(Number(fromSlot) || 1, 1, panel.slots);
const targetSlot = clamp(Number(toSlot) || 1, 1, panel.slots);
if (sourceSlot === targetSlot) return false;
const source = hockeyPlayerSelectionSlot(panel, sourceSlot);
if (!source.id && !source.dbId) return false;
const target = hockeyPlayerSelectionSlot(panel, targetSlot);
const sourcePlayer = { externalId: source.id, dbId: source.dbId, teamId: source.teamId, side: source.side, number: source.number, name: source.name };
const targetPlayer = (target.id || target.dbId)
? { externalId: target.id, dbId: target.dbId, teamId: target.teamId, side: target.side, number: target.number, name: target.name }
: null;
const patch = {
...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer),
...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer),
};
await hockeySetMatchValues(patch, { refreshMapping: true });
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(sourcePlayer, source.teamId);
toast(targetPlayer ? `${panel.label}: игроки ${sourceSlot} и ${targetSlot} поменяны местами` : `${panel.label}: игрок перемещён ${sourceSlot}${targetSlot}`);
return true;
}
async function hockeyClearPlayerSelectionSlot(panel, slot) { async function hockeyClearPlayerSelectionSlot(panel, slot) {
await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true }); await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true });
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, ""); if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, "");
@@ -8569,10 +8608,10 @@ function renderHockeyPlayerSelectionWindows() {
const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : ""; const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : "";
const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока"); const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока");
const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа"; const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа";
return `<div class="hockey-player-select-slot ${filledSlot ? "is-filled" : ""}" data-player-panel-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}"> return `<div class="hockey-player-select-slot ${filledSlot ? "is-filled" : ""}" data-player-panel-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}" ${filledSlot ? `draggable="true" title="Перетащите на другой слот, чтобы поменять игроков местами"` : ""}>
<span class="hockey-player-select-index">${number}</span> <span class="hockey-player-select-index">${number}</span>
<div><strong>${escapeHtml(title)}</strong><small>${escapeHtml(ids)}</small></div> <div><strong>${escapeHtml(title)}</strong><small>${escapeHtml(ids)}</small></div>
${filledSlot ? `<button type="button" data-player-panel-clear-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}" title="Очистить слот">×</button>` : `<i>DROP</i>`} ${filledSlot ? `<span class="hockey-player-select-drag" aria-hidden="true">⋮⋮</span><button type="button" data-player-panel-clear-slot="${number}" data-player-panel-id="${escapeHtml(panel.id)}" title="Очистить слот">×</button>` : `<i>DROP</i>`}
</div>`; </div>`;
}).join("")} }).join("")}
</div> </div>
@@ -8584,7 +8623,29 @@ function renderHockeyPlayerSelectionWindows() {
renderRuntime(); renderRuntime();
}); });
node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => { node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => {
slotNode.addEventListener("dragstart", (event) => {
const slot = Number(slotNode.dataset.playerPanelSlot || 1);
const current = hockeyPlayerSelectionSlot(panel, slot);
if (!current.id && !current.dbId) { event.preventDefault(); return; }
state.hockeyPlayerSelectionDrag = { panelId: panel.id, slot };
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-ui-builder-hockey-player-selection-slot", JSON.stringify(state.hockeyPlayerSelectionDrag));
event.dataTransfer.setData("text/plain", current.name || current.id || `player-${slot}`);
slotNode.classList.add("is-dragging");
});
slotNode.addEventListener("dragend", () => {
state.hockeyPlayerSelectionDrag = null;
slotNode.classList.remove("is-dragging");
node.querySelectorAll(".hockey-player-select-slot.is-over").forEach((item) => item.classList.remove("is-over"));
});
slotNode.addEventListener("dragover", (event) => { slotNode.addEventListener("dragover", (event) => {
const internal = state.hockeyPlayerSelectionDrag;
if (internal && internal.panelId === panel.id) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
slotNode.classList.add("is-over");
return;
}
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
if (!player) return; if (!player) return;
event.preventDefault(); event.preventDefault();
@@ -8595,9 +8656,17 @@ function renderHockeyPlayerSelectionWindows() {
slotNode.addEventListener("drop", async (event) => { slotNode.addEventListener("drop", async (event) => {
event.preventDefault(); event.preventDefault();
slotNode.classList.remove("is-over"); slotNode.classList.remove("is-over");
const targetSlot = Number(slotNode.dataset.playerPanelSlot || 1);
const internal = state.hockeyPlayerSelectionDrag;
if (internal && internal.panelId === panel.id) {
const sourceSlot = Number(internal.slot || 1);
state.hockeyPlayerSelectionDrag = null;
await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot);
return;
}
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player"); const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
if (!player) return; if (!player) return;
await hockeySetPlayerSelectionSlot(panel, Number(slotNode.dataset.playerPanelSlot || 1), player); await hockeySetPlayerSelectionSlot(panel, targetSlot, player);
}); });
}); });
node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => { node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => {

View File

@@ -7363,3 +7363,73 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hockey-runtime-side-stack { flex-basis:290px; width:290px; } .hockey-runtime-side-stack { flex-basis:290px; width:290px; }
.player-selection-editor-row { grid-template-columns:1fr 1fr; } .player-selection-editor-row { grid-template-columns:1fr 1fr; }
} }
/* Build82 — quick-panel settings tabs + reorderable player slots. */
.prematch-editor-tabs {
display:flex;
align-items:center;
gap:6px;
padding:4px;
border:1px solid #2a4058;
border-radius:11px;
background:#0b1725;
}
.prematch-editor-tab {
min-height:34px;
display:flex;
align-items:center;
justify-content:center;
gap:7px;
padding:0 14px;
color:#7890a8;
border:1px solid transparent;
border-radius:8px;
background:transparent;
font-size:10px;
font-weight:950;
cursor:pointer;
}
.prematch-editor-tab:hover { color:#c8d7e5; background:#102238; }
.prematch-editor-tab.active { color:#eaf5ff; border-color:#365a72; background:#14304a; box-shadow:inset 0 -2px 0 #48dfbd; }
.prematch-editor-tab span {
min-width:20px;
height:18px;
display:grid;
place-items:center;
padding:0 5px;
border-radius:9px;
color:#a9c1d5;
background:#203b53;
font:900 8px "Roboto Mono",Consolas,monospace;
}
.prematch-editor-section { min-height:0; }
.player-selection-editor.prematch-editor-section {
min-height:0;
margin-top:0;
padding-top:0;
border-top:0;
display:grid;
grid-template-rows:auto minmax(0,1fr);
}
.player-selection-editor.prematch-editor-section .player-selection-editor-list {
min-height:0;
overflow:auto;
padding-right:4px;
overscroll-behavior:contain;
}
.hockey-player-select-slot { grid-template-columns:27px minmax(0,1fr) auto auto; }
.hockey-player-select-slot[draggable="true"] { cursor:grab; }
.hockey-player-select-slot[draggable="true"]:active { cursor:grabbing; }
.hockey-player-select-slot.is-dragging { opacity:.48; border-color:#48dfbd; }
.hockey-player-select-drag {
width:16px;
display:grid;
place-items:center;
color:#58748b;
font-size:10px;
font-weight:950;
letter-spacing:-2px;
user-select:none;
}
.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }