fix(dashboard): stop showing perpetual MODIFIED for un-edited legacy layouts
The MODIFIED hint in the Customize Dashboard panel was driven by `presetActive`, recomputed on every save/load via strict deep-equal against each preset. Any drift between a saved layout and the current defaults — older app versions that hadn't yet had some new perf cells added, prior buggy merges that appended new registry keys to the end of perfCells, or stale `visible` values from intermediate dev builds — left `presetActive` undefined forever and pinned the panel in MODIFIED state for users who had not actually edited anything. Split the two concerns: - `presetActive` keeps driving the chip highlight (recomputed). When the layout happens to match a preset exactly the chip lights up. - New `userModified` boolean drives the MODIFIED indicator. Set to true only on actual edits through the panel (visibility / density / ordering / select changes) and on JSON import; cleared by applying a preset and by Reset. Legacy saves without the field load as `userModified: false` so the indicator no longer fires retroactively on data the user never touched. Also tighten `_mergeWithDefaults` so newly-added registry keys land at their canonical positions (subsequence detection) when the saved order is consistent with defaults, which keeps the chip highlight stable across upgrades.
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH,
|
ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
|
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||||
|
|
||||||
const ICON_DRAG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
|
const ICON_DRAG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
|
||||||
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||||
@@ -173,6 +174,10 @@ function _renderPanelBody(): void {
|
|||||||
${_renderActions()}
|
${_renderActions()}
|
||||||
`;
|
`;
|
||||||
_bindHandlers(body);
|
_bindHandlers(body);
|
||||||
|
// Enhance the compact <select> elements rendered above into MiniSelect
|
||||||
|
// popups. The native elements stay in the DOM hidden, so the change
|
||||||
|
// handlers attached in _bindHandlers continue to fire on selection.
|
||||||
|
enhanceMiniSelects(body, 'select.dash-cust-mini-select');
|
||||||
if (_focusAfterRender) {
|
if (_focusAfterRender) {
|
||||||
const el = body.querySelector<HTMLElement>(_focusAfterRender);
|
const el = body.querySelector<HTMLElement>(_focusAfterRender);
|
||||||
el?.focus();
|
el?.focus();
|
||||||
@@ -189,9 +194,9 @@ function _renderPresets(layout: DashboardLayoutV1): string {
|
|||||||
${t('dashboard.customize.preset.' + name)}
|
${t('dashboard.customize.preset.' + name)}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const modifiedHint = layout.presetActive
|
const modifiedHint = layout.userModified
|
||||||
? ''
|
? `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`
|
||||||
: `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`;
|
: '';
|
||||||
return `<section class="dash-cust-section">
|
return `<section class="dash-cust-section">
|
||||||
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
|
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
|
||||||
<div class="dash-cust-chips">${chips}</div>
|
<div class="dash-cust-chips">${chips}</div>
|
||||||
|
|||||||
@@ -110,8 +110,18 @@ export interface DashboardLayoutV1 {
|
|||||||
perfCells: PerfCellConfig[];
|
perfCells: PerfCellConfig[];
|
||||||
global: GlobalConfig;
|
global: GlobalConfig;
|
||||||
/** Active preset key when the layout matches a built-in unmodified.
|
/** Active preset key when the layout matches a built-in unmodified.
|
||||||
* Cleared on any user edit so the panel can show "modified" state. */
|
* Drives the chip highlight in the customize panel — recomputed on
|
||||||
|
* every save/load so a user who toggles back to a preset's exact
|
||||||
|
* shape sees the chip light up again. */
|
||||||
presetActive?: string;
|
presetActive?: string;
|
||||||
|
/** True iff the user has made changes via the customize panel since
|
||||||
|
* the last preset apply / reset / import. Drives the "MODIFIED"
|
||||||
|
* indicator. Kept separate from `presetActive` so that legacy
|
||||||
|
* layouts whose data drifted from every preset (older app versions
|
||||||
|
* saved with different defaults, prior buggy migrations) don't
|
||||||
|
* flash a perpetual "MODIFIED" warning at users who never actually
|
||||||
|
* customized anything. */
|
||||||
|
userModified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _defaultSection = (key: string, visible = true): SectionConfig => ({
|
const _defaultSection = (key: string, visible = true): SectionConfig => ({
|
||||||
@@ -226,6 +236,7 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
|
|||||||
perfCells: layout.perfCells.map(c => ({ ...c })),
|
perfCells: layout.perfCells.map(c => ({ ...c })),
|
||||||
global: { ...layout.global },
|
global: { ...layout.global },
|
||||||
presetActive,
|
presetActive,
|
||||||
|
userModified: layout.userModified,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,16 +440,22 @@ async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a built-in preset and persist it. */
|
/** Apply a built-in preset and persist it. Clears the `userModified`
|
||||||
|
* flag — applying a preset is the user's signal that they want to
|
||||||
|
* start from a clean baseline. */
|
||||||
export function applyDashboardPreset(name: string): void {
|
export function applyDashboardPreset(name: string): void {
|
||||||
const factory = PRESETS[name];
|
const factory = PRESETS[name];
|
||||||
if (!factory) return;
|
if (!factory) return;
|
||||||
saveDashboardLayout(factory());
|
const next = factory();
|
||||||
|
next.userModified = false;
|
||||||
|
saveDashboardLayout(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset to the studio default. */
|
/** Reset to the studio default. Clears `userModified`. */
|
||||||
export function resetDashboardLayout(): void {
|
export function resetDashboardLayout(): void {
|
||||||
saveDashboardLayout(PRESETS.studio());
|
const next = PRESETS.studio();
|
||||||
|
next.userModified = false;
|
||||||
|
saveDashboardLayout(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export the current layout as a downloadable JSON string. */
|
/** Export the current layout as a downloadable JSON string. */
|
||||||
@@ -453,6 +470,7 @@ export function importDashboardLayoutJson(json: string): boolean {
|
|||||||
if (!parsed || typeof parsed !== 'object') return false;
|
if (!parsed || typeof parsed !== 'object') return false;
|
||||||
const merged = _mergeWithDefaults(parsed);
|
const merged = _mergeWithDefaults(parsed);
|
||||||
merged.presetActive = undefined;
|
merged.presetActive = undefined;
|
||||||
|
merged.userModified = true;
|
||||||
saveDashboardLayout(merged);
|
saveDashboardLayout(merged);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -514,6 +532,7 @@ export function setSectionVisible(layout: DashboardLayoutV1, key: string, visibl
|
|||||||
const s = next.sections.find(s => s.key === key);
|
const s = next.sections.find(s => s.key === key);
|
||||||
if (s) s.visible = visible;
|
if (s) s.visible = visible;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +548,7 @@ export function setSectionOrder(layout: DashboardLayoutV1, orderedKeys: string[]
|
|||||||
for (const s of map.values()) reordered.push(s);
|
for (const s of map.values()) reordered.push(s);
|
||||||
next.sections = reordered;
|
next.sections = reordered;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +557,7 @@ export function setSectionDensity(layout: DashboardLayoutV1, key: string, densit
|
|||||||
const s = next.sections.find(s => s.key === key);
|
const s = next.sections.find(s => s.key === key);
|
||||||
if (s) s.density = density;
|
if (s) s.density = density;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +566,7 @@ export function setSectionCollapsedDefault(layout: DashboardLayoutV1, key: strin
|
|||||||
const s = next.sections.find(s => s.key === key);
|
const s = next.sections.find(s => s.key === key);
|
||||||
if (s) s.collapsedDefault = collapsed;
|
if (s) s.collapsedDefault = collapsed;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +575,7 @@ export function setPerfCellVisible(layout: DashboardLayoutV1, key: string, visib
|
|||||||
const c = next.perfCells.find(c => c.key === key);
|
const c = next.perfCells.find(c => c.key === key);
|
||||||
if (c) c.visible = visible;
|
if (c) c.visible = visible;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +590,7 @@ export function setPerfCellOrder(layout: DashboardLayoutV1, orderedKeys: string[
|
|||||||
for (const c of map.values()) reordered.push(c);
|
for (const c of map.values()) reordered.push(c);
|
||||||
next.perfCells = reordered;
|
next.perfCells = reordered;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +599,7 @@ export function setPerfCellMode(layout: DashboardLayoutV1, key: string, mode: Pe
|
|||||||
const c = next.perfCells.find(c => c.key === key);
|
const c = next.perfCells.find(c => c.key === key);
|
||||||
if (c) c.mode = mode;
|
if (c) c.mode = mode;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +608,7 @@ export function setPerfCellWindow(layout: DashboardLayoutV1, key: string, window
|
|||||||
const c = next.perfCells.find(c => c.key === key);
|
const c = next.perfCells.find(c => c.key === key);
|
||||||
if (c) c.window = window;
|
if (c) c.window = window;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +616,7 @@ export function setGlobalPerfWindow(layout: DashboardLayoutV1, window: SampleWin
|
|||||||
const next = _clone(layout);
|
const next = _clone(layout);
|
||||||
next.global.perfWindow = window;
|
next.global.perfWindow = window;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +625,7 @@ export function setPerfCellYScale(layout: DashboardLayoutV1, key: string, yScale
|
|||||||
const c = next.perfCells.find(c => c.key === key);
|
const c = next.perfCells.find(c => c.key === key);
|
||||||
if (c) c.yScale = yScale;
|
if (c) c.yScale = yScale;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +633,7 @@ export function setGlobalPerfMode(layout: DashboardLayoutV1, mode: 'system' | 'a
|
|||||||
const next = _clone(layout);
|
const next = _clone(layout);
|
||||||
next.global.perfMode = mode;
|
next.global.perfMode = mode;
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,53 +641,97 @@ export function setGlobalConfig(layout: DashboardLayoutV1, patch: Partial<Global
|
|||||||
const next = _clone(layout);
|
const next = _clone(layout);
|
||||||
next.global = { ...next.global, ...patch };
|
next.global = { ...next.global, ...patch };
|
||||||
next.presetActive = undefined;
|
next.presetActive = undefined;
|
||||||
|
next.userModified = true;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal: merge / migrate ────────────────────────────────────────────
|
// ── Internal: merge / migrate ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Merge a saved keyed array (sections, perfCells) with the canonical
|
||||||
|
* defaults. Unknown keys in `saved` are dropped; missing keys are filled
|
||||||
|
* from defaults.
|
||||||
|
*
|
||||||
|
* Ordering: when the saved keys form a *subsequence* of the canonical
|
||||||
|
* order — i.e. the user never manually reordered, they just happen to
|
||||||
|
* have an older snapshot missing newly-added registry entries — the
|
||||||
|
* merged result uses the canonical order. Without this, every new
|
||||||
|
* default key added in a later release would land at the end of the
|
||||||
|
* user's saved list, drifting the layout off every preset and pinning
|
||||||
|
* the customize panel in "MODIFIED" forever after an upgrade.
|
||||||
|
*
|
||||||
|
* When the user *did* reorder (saved order is not a subsequence of
|
||||||
|
* default), we preserve their order and append any new default keys at
|
||||||
|
* the end — those entries are new to the user, so any position is a
|
||||||
|
* guess; end-of-list is at least predictable. */
|
||||||
|
function _mergeKeyedArray<T extends { key: string }>(
|
||||||
|
defaults: T[],
|
||||||
|
saved: T[],
|
||||||
|
mergeItem: (def: T, s: T) => T,
|
||||||
|
): T[] {
|
||||||
|
const defByKey = new Map(defaults.map(d => [d.key, d]));
|
||||||
|
const savedKnown = saved.filter(s => defByKey.has(s.key));
|
||||||
|
const savedByKey = new Map(savedKnown.map(s => [s.key, s]));
|
||||||
|
|
||||||
|
const defaultKeys = defaults.map(d => d.key);
|
||||||
|
const savedKeys = savedKnown.map(s => s.key);
|
||||||
|
let i = 0;
|
||||||
|
for (const dk of defaultKeys) {
|
||||||
|
if (i < savedKeys.length && dk === savedKeys[i]) i++;
|
||||||
|
}
|
||||||
|
const userReordered = i !== savedKeys.length;
|
||||||
|
|
||||||
|
const finalKeys = userReordered
|
||||||
|
? [...savedKeys, ...defaultKeys.filter(k => !savedByKey.has(k))]
|
||||||
|
: defaultKeys;
|
||||||
|
|
||||||
|
return finalKeys.map(k => {
|
||||||
|
const def = defByKey.get(k)!;
|
||||||
|
const s = savedByKey.get(k);
|
||||||
|
return s ? mergeItem(def, s) : { ...def };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Merge a (possibly partial or older) layout with current defaults. New
|
/** Merge a (possibly partial or older) layout with current defaults. New
|
||||||
* registry keys not in the saved layout are appended to the end with
|
* registry keys not in the saved layout are inserted at their canonical
|
||||||
* default settings; unknown keys in the saved layout are dropped. */
|
* positions when the saved order is consistent with defaults; otherwise
|
||||||
|
* appended at the end (see `_mergeKeyedArray`). Unknown keys in the
|
||||||
|
* saved layout are dropped. */
|
||||||
function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
|
function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
|
||||||
const base = _clone(DEFAULT_LAYOUT);
|
const base = _clone(DEFAULT_LAYOUT);
|
||||||
if (!input || typeof input !== 'object') return base;
|
if (!input || typeof input !== 'object') return base;
|
||||||
const obj = input as Partial<DashboardLayoutV1>;
|
const obj = input as Partial<DashboardLayoutV1>;
|
||||||
|
|
||||||
if (Array.isArray(obj.sections)) {
|
if (Array.isArray(obj.sections)) {
|
||||||
const known = new Map(base.sections.map(s => [s.key, s]));
|
base.sections = _mergeKeyedArray(
|
||||||
const reordered: SectionConfig[] = [];
|
base.sections,
|
||||||
for (const s of obj.sections as SectionConfig[]) {
|
obj.sections as SectionConfig[],
|
||||||
const def = known.get(s.key);
|
(def, s) => ({
|
||||||
if (!def) continue;
|
|
||||||
reordered.push({
|
|
||||||
...def,
|
...def,
|
||||||
...s,
|
...s,
|
||||||
options: { ...def.options, ...(s.options || {}) },
|
options: { ...def.options, ...(s.options || {}) },
|
||||||
});
|
}),
|
||||||
known.delete(s.key);
|
);
|
||||||
}
|
|
||||||
for (const s of known.values()) reordered.push(s);
|
|
||||||
base.sections = reordered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj.perfCells)) {
|
if (Array.isArray(obj.perfCells)) {
|
||||||
const known = new Map(base.perfCells.map(c => [c.key, c]));
|
base.perfCells = _mergeKeyedArray(
|
||||||
const reordered: PerfCellConfig[] = [];
|
base.perfCells,
|
||||||
for (const c of obj.perfCells as PerfCellConfig[]) {
|
obj.perfCells as PerfCellConfig[],
|
||||||
const def = known.get(c.key);
|
(def, c) => ({ ...def, ...c }),
|
||||||
if (!def) continue;
|
);
|
||||||
reordered.push({ ...def, ...c });
|
|
||||||
known.delete(c.key);
|
|
||||||
}
|
|
||||||
for (const c of known.values()) reordered.push(c);
|
|
||||||
base.perfCells = reordered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (obj.global && typeof obj.global === 'object') {
|
if (obj.global && typeof obj.global === 'object') {
|
||||||
base.global = { ...base.global, ...obj.global };
|
base.global = { ...base.global, ...obj.global };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy saves (before `userModified` existed) omit the field. Treat
|
||||||
|
// them as un-modified so existing users with naturally-drifted layouts
|
||||||
|
// don't see a perpetual MODIFIED hint. New mutations through the panel
|
||||||
|
// will set the flag and surface MODIFIED correctly going forward.
|
||||||
|
base.userModified = typeof obj.userModified === 'boolean'
|
||||||
|
? obj.userModified
|
||||||
|
: false;
|
||||||
base.presetActive = _computeActivePreset(base);
|
base.presetActive = _computeActivePreset(base);
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user