Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions IntelPresentMon/AppCef/ipm-ui-vue/src/stores/loadout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ref, reactive, readonly, computed } from 'vue'
import { ref, reactive, readonly, computed } from 'vue'
import { defineStore } from 'pinia'
import { Api } from '@/core/api'
import { getEnumValues } from '@/core/meta'
import { asGraph, asReadout, WidgetType, type Widget } from '@/core/widget'
import { asGraph, asReadout, WidgetType, type Widget, regenerateKeys as regenerateWidgetKeys, resetKeySequence as resetWidgetKeySequence } from '@/core/widget'
import { signature, type LoadoutFile } from '@/core/loadout'
import type { QualifiedMetric } from '@/core/qualified-metric'
import { makeDefaultGraph, type Graph } from '@/core/graph'
import { makeDefaultReadout, type Readout } from '@/core/readout'
import { makeDefaultWidgetMetric, type WidgetMetric } from '@/core/widget-metric'
import { makeDefaultWidgetMetric, type WidgetMetric, resetKeySequence as resetWidgetMetricKeySequence } from '@/core/widget-metric'
import { debounce, type DelayedTask } from '@/core/timing'
import { migrateLoadout } from '@/core/loadout-migration'
import { useIntrospectionStore } from './introspection'
Expand All @@ -34,6 +34,14 @@ export const useLoadoutStore = defineStore('loadout', () => {
})

// === Functions ===
function normalizeWidgetKeys(items: Widget[]) {
resetWidgetKeySequence()
resetWidgetMetricKeySequence()
for (const widget of items) {
regenerateWidgetKeys(widget)
}
}

// loads loadout from json string data without any error handling
async function parseAndReplace(payload: string) {
const loadout = JSON.parse(payload) as LoadoutFile
Expand All @@ -45,6 +53,7 @@ export const useLoadoutStore = defineStore('loadout', () => {
console.info(`loadout migrated to ${signature.version}`)
}
loadout.widgets = loadout.widgets.filter(w => w.metrics.length > 0)
normalizeWidgetKeys(loadout.widgets)
widgets.value.splice(0, widgets.value.length, ...loadout.widgets)
}

Expand Down Expand Up @@ -138,8 +147,25 @@ export const useLoadoutStore = defineStore('loadout', () => {
}

async function moveWidget(from: number, to: number) {
const movedItem = widgets.value.splice(from, 1)[0]
widgets.value.splice(to, 0, movedItem)
if (!Number.isInteger(from) || !Number.isInteger(to)) {
return
}
if (from < 0 || from >= widgets.value.length) {
return
}
if (to < 0 || to > widgets.value.length) {
return
}
if (from === to) {
return
}
const movedItem = widgets.value[from]
if (movedItem === undefined) {
return
}
widgets.value.splice(from, 1)
const insertIndex = Math.min(to, widgets.value.length)
widgets.value.splice(insertIndex, 0, movedItem)
}

// wraps parseAndReplace in try/catch and handles errors
Expand Down Expand Up @@ -181,4 +207,4 @@ export const useLoadoutStore = defineStore('loadout', () => {
browseAndSerialize,
serializeCurrent
}
})
})
69 changes: 63 additions & 6 deletions IntelPresentMon/AppCef/ipm-ui-vue/src/views/LoadoutConfigView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Sortable from 'sortablejs';
import { useIntrospectionStore } from '@/stores/introspection';
import type { Widget } from '@/core/widget';
import LoadoutRow from '@/components/LoadoutRow.vue';
import { onMounted, ref } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { useLoadoutStore } from '@/stores/loadout';
import { useNotificationsStore } from '@/stores/notifications';
import { Api } from '@/core/api';
Expand All @@ -15,30 +15,86 @@ const loadout = useLoadoutStore()
const notes = useNotificationsStore()

const activeAdapterId = ref<number|null>(null);
// Sortable instance used to manage drag-and-drop reordering for widget rows.
// This is created on mount and destroyed on unmount to avoid stale DOM references.
let sort: Sortable|null = null;

// Convert Sortable event indices into validated array indices.
// Sortable can report undefined (no drop), negative indices (drag outside),
// or out-of-range indices when the drop target is not inside the container.
// allowEnd controls whether we accept an index equal to length (append).
const getSortableIndex = (value: number | null | undefined, length: number, allowEnd: boolean): number | null => {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return null;
}
if (value < 0) {
return null;
}
if (allowEnd) {
return value <= length ? value : null;
}
return value < length ? value : null;
};

// Restore DOM order to match the store order when a drag ends in an invalid state.
// This avoids the UI showing a different order than the canonical loadout data.
const resetSortOrder = () => {
if (!sort) {
return;
}
const order = loadout.widgets.map(w => String(w.key));
if (order.length === 0) {
return;
}
sort.sort(order);
};

onMounted(() => {
// hook up the Sortable.js drag and drop machinery to our elements
sort = new Sortable(document.querySelector('#sortable-row-container')!, {
// Allow only the widget rows (not the add button or other elements) to be draggable.
draggable: '.sortable-row',
// Require dragging from the grip icon to avoid accidental drags while editing fields.
handle: '.sortable-handle',
// Use the fallback drag implementation so we control visuals consistently across browsers.
forceFallback: true,
// Identify each row by data-id so Sortable can reorder by stable widget keys.
dataIdAttr: 'data-id',
// Maintain a grabbing cursor on the dragged element for consistent feedback.
onChoose: e => e.target.classList.add('sortable-grabbing'),
onUnchoose: e => e.target.classList.remove('sortable-grabbing'),
onStart: e => e.target.classList.add('sortable-grabbing'),
onEnd: e => {
// Always clear the grabbing cursor and attempt to apply the reorder.
e.target.classList.remove('sortable-grabbing')
dragReorder(e)
},
})
})

// Apply the reorder to the store if indices are valid, otherwise revert DOM order.
const dragReorder = (e: Sortable.SortableEvent) => {
if (e.oldIndex !== undefined && e.newIndex !== undefined) {
loadout.moveWidget(e.oldIndex, e.newIndex)
const length = loadout.widgets.length;
// oldIndex must be a valid index into the current list (no "append" allowed).
const from = getSortableIndex(e.oldIndex, length, false);
// newIndex allows append, but still must be within bounds.
const to = getSortableIndex(e.newIndex, length, true);
if (from === null || to === null || from === to) {
resetSortOrder();
return;
}
// Delegate to the store to ensure any other logic stays centralized.
loadout.moveWidget(from, to);
}

onUnmounted(() => {
// Destroy Sortable on teardown to avoid handlers firing on a stale DOM.
if (sort) {
sort.destroy();
sort = null;
}
});

async function save() {
try {
await loadout.browseAndSerialize()
Expand Down Expand Up @@ -82,15 +138,15 @@ const removeWidget = (widgetIdx:number) => {

<v-row class="mt-5 loadout-table" id="sortable-row-container">
<loadout-row
v-for="(w, i) in loadout.widgets" :key="w.key" :stats="intro.stats"
v-for="(w, i) in loadout.widgets" :key="w.key" :data-id="w.key" :stats="intro.stats"
:widgetIdx="i" :widgets="loadout.widgets" :metrics="intro.metrics"
:metricOptions="intro.metricOptions" :adapterId="activeAdapterId" :locked="false"
@delete="removeWidget"
></loadout-row>
</v-row>
<div class="add-btn-row">
<v-btn @click="addWidget()" class="add-btn" variant="tonal" height="48" color="white">Add New Widget</v-btn>
</div>
</v-row>

<v-row>
<v-col cols="6" style="text-align: center"><v-btn @click="save()" variant="tonal" color="white">Save</v-btn></v-col>
Expand Down Expand Up @@ -188,6 +244,7 @@ const removeWidget = (widgetIdx:number) => {
width: 100%;
display: flex;
justify-content: center;
margin-top: 12px;
}
.sortable-grabbing * {
cursor: grabbing !important;
Expand All @@ -207,4 +264,4 @@ const removeWidget = (widgetIdx:number) => {
.link-head:hover {
color: rgb(var(--v-theme-primary));
}
</style>
</style>