fix(content-editor): sync and auto-save velxio arduino draft on publish
- Auto-save dirty draft before publishing in ContentEditorManager so in-memory Velxio circuit/code changes are never lost when clicking Publish directly - Add resolveRestoredContent and baseline tracking to velxio-manager so lesson view immediately reflects updated server code instead of stale localStorage - Invalidate in-memory lesson and markdown caches on draft publish and file operations - Update teacher content page publish button disabled condition to check saving state
This commit is contained in:
parent
76e0210a9d
commit
7d590e0f95
|
|
@ -133,9 +133,24 @@ export class ContentEditorManager {
|
|||
}
|
||||
|
||||
async handlePublish() {
|
||||
if (!this.draftId || this.publishing) return;
|
||||
if (!this.activePath || this.publishing) return;
|
||||
this.publishing = true;
|
||||
try {
|
||||
if (this.dirty || !this.draftId) {
|
||||
const saveRes = await saveDraft(this.activePath, this.body, this.baseMtime);
|
||||
if (!saveRes.success) {
|
||||
this.showMessage('error', saveRes.message || 'Gagal menyimpan draft sebelum publish');
|
||||
return;
|
||||
}
|
||||
this.draftId = saveRes.draft_id;
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
if (!this.draftId) {
|
||||
this.showMessage('error', 'Draft ID tidak ditemukan untuk dipublikasikan');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await publishDraft(this.draftId);
|
||||
if (res.success) {
|
||||
this.dirty = false;
|
||||
|
|
|
|||
|
|
@ -58,4 +58,36 @@ describe('renameEntry', () => {
|
|||
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body ?? '{}');
|
||||
expect(body.confirm_critical).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDraft and publishDraft API', () => {
|
||||
it('mengirim saveDraft dengan target_path dan body', async () => {
|
||||
const { saveDraft } = await import('./contentEditor');
|
||||
const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true, draft_id: 'draft-1' }) });
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await saveDraft('arduino/led_blink_arduino.md', 'modified body', 12345);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(call[0]).toBe('/api/content/drafts');
|
||||
expect(JSON.parse(call[1].body)).toEqual({
|
||||
target_path: 'arduino/led_blink_arduino.md',
|
||||
body: 'modified body',
|
||||
base_mtime: 12345
|
||||
});
|
||||
});
|
||||
|
||||
it('mengirim publishDraft dengan draft_id ke endpoint publish', async () => {
|
||||
const { publishDraft } = await import('./contentEditor');
|
||||
const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true, published_path: 'arduino/led_blink_arduino.md' }) });
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await publishDraft('draft-1');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(call[0]).toBe('/api/content/drafts/draft-1/publish');
|
||||
expect(call[1].method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getVelxioState } from './velxio-manager';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getVelxioState, resolveRestoredContent } from './velxio-manager';
|
||||
|
||||
class LocalStorageMock {
|
||||
private store: Record<string, string> = {};
|
||||
getItem(key: string) { return this.store[key] ?? null; }
|
||||
setItem(key: string, val: string) { this.store[key] = String(val); }
|
||||
removeItem(key: string) { delete this.store[key]; }
|
||||
clear() { this.store = {}; }
|
||||
}
|
||||
const mockStorage = new LocalStorageMock();
|
||||
(global as any).localStorage = mockStorage;
|
||||
(global as any).window = { localStorage: mockStorage };
|
||||
|
||||
describe('velxio-manager', () => {
|
||||
describe('getVelxioState', () => {
|
||||
|
|
@ -137,4 +148,69 @@ describe('velxio-manager', () => {
|
|||
expect(parsedCircuit.wires).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRestoredContent', () => {
|
||||
const KEY = 'test_arduino_code';
|
||||
const KEY_BASE = 'test_arduino_code_base';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns initial content when localStorage is empty', () => {
|
||||
const res = resolveRestoredContent(KEY, 'void setup() {}');
|
||||
expect(res.content).toBe('void setup() {}');
|
||||
expect(res.shouldRestore).toBe(false);
|
||||
});
|
||||
|
||||
it('clears legacy draft (missing _base) and loads fresh server initial content', () => {
|
||||
localStorage.setItem(KEY, 'legacy code from old auto-save');
|
||||
const res = resolveRestoredContent(KEY, 'void setup() { Serial.begin(9600); }');
|
||||
expect(res.content).toBe('void setup() { Serial.begin(9600); }');
|
||||
expect(res.shouldRestore).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears draft when server initial content has changed (outdated baseline)', () => {
|
||||
localStorage.setItem(KEY, 'student modified code');
|
||||
localStorage.setItem(KEY_BASE, 'old initial code v1');
|
||||
|
||||
const res = resolveRestoredContent(KEY, 'new initial code v2 published by teacher');
|
||||
expect(res.content).toBe('new initial code v2 published by teacher');
|
||||
expect(res.shouldRestore).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
expect(localStorage.getItem(KEY_BASE)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears draft when saved code is identical to initial content (unmodified)', () => {
|
||||
localStorage.setItem(KEY, 'void setup() {}');
|
||||
localStorage.setItem(KEY_BASE, 'void setup() {}');
|
||||
|
||||
const res = resolveRestoredContent(KEY, 'void setup() {}');
|
||||
expect(res.content).toBe('void setup() {}');
|
||||
expect(res.shouldRestore).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('restores student draft when baseline matches and code was modified', () => {
|
||||
localStorage.setItem(KEY, 'void setup() { pinMode(13, OUTPUT); }');
|
||||
localStorage.setItem(KEY_BASE, 'void setup() {}');
|
||||
|
||||
const res = resolveRestoredContent(KEY, 'void setup() {}');
|
||||
expect(res.content).toBe('void setup() { pinMode(13, OUTPUT); }');
|
||||
expect(res.shouldRestore).toBe(true);
|
||||
expect(localStorage.getItem(KEY)).toBe('void setup() { pinMode(13, OUTPUT); }');
|
||||
});
|
||||
|
||||
it('always returns initial content and cleans up storage when isTeacher is true', () => {
|
||||
localStorage.setItem(KEY, 'some draft code');
|
||||
localStorage.setItem(KEY_BASE, 'void setup() {}');
|
||||
|
||||
const res = resolveRestoredContent(KEY, 'void setup() {}', true);
|
||||
expect(res.content).toBe('void setup() {}');
|
||||
expect(res.shouldRestore).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
expect(localStorage.getItem(KEY_BASE)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,43 @@ export function getVelxioState(velxioIframe: HTMLIFrameElement | null): { code:
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveRestoredContent(
|
||||
storageKey: string,
|
||||
initialContent: string | undefined,
|
||||
isTeacher = false
|
||||
): { content: string; shouldRestore: boolean } {
|
||||
const initial = initialContent ?? '';
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return { content: initial, shouldRestore: false };
|
||||
}
|
||||
|
||||
if (isTeacher) {
|
||||
localStorage.removeItem(storageKey);
|
||||
localStorage.removeItem(storageKey + '_base');
|
||||
return { content: initial, shouldRestore: false };
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const savedBase = localStorage.getItem(storageKey + '_base');
|
||||
|
||||
// A draft is outdated if its baseline doesn't match the current initial content,
|
||||
// or if it was saved by legacy code (no baseline recorded).
|
||||
const isLegacy = savedBase === null;
|
||||
const isOutdated = isLegacy || savedBase !== initial;
|
||||
const isUnmodified = saved === initial || (savedBase !== null && saved === savedBase);
|
||||
|
||||
if (saved && !isOutdated && !isUnmodified) {
|
||||
return { content: saved, shouldRestore: true };
|
||||
}
|
||||
|
||||
// Clean up stale or unmodified draft from storage
|
||||
if (saved !== null || savedBase !== null) {
|
||||
localStorage.removeItem(storageKey);
|
||||
localStorage.removeItem(storageKey + '_base');
|
||||
}
|
||||
return { content: initial, shouldRestore: false };
|
||||
}
|
||||
|
||||
export function initVelxioBridge(
|
||||
iframe: HTMLIFrameElement,
|
||||
data: LessonContent | null,
|
||||
|
|
@ -83,7 +120,8 @@ export function initVelxioBridge(
|
|||
arduinoCodeKey: string,
|
||||
onReady: (bridge: VelxioBridge) => void,
|
||||
onSubmit: () => void,
|
||||
onCountdown?: (msRemaining: number) => void
|
||||
onCountdown?: (msRemaining: number) => void,
|
||||
isTeacher = false
|
||||
): () => void {
|
||||
let settled = false;
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
@ -104,22 +142,22 @@ export function initVelxioBridge(
|
|||
lockComponents: true
|
||||
});
|
||||
|
||||
// Priority: Restore from localStorage if available, otherwise use data from backend
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
console.log('[Velxio Bridge] Restoring circuit from draft');
|
||||
velxioBridge.loadCircuit(savedCircuit);
|
||||
} else if (data.velxio_circuit) {
|
||||
velxioBridge.loadCircuit(data.velxio_circuit);
|
||||
// Priority: Restore from localStorage if modified by student on current baseline,
|
||||
// otherwise use latest data from backend.
|
||||
const restoredCircuit = resolveRestoredContent(arduinoCircuitKey, data.velxio_circuit, isTeacher);
|
||||
if (restoredCircuit.content) {
|
||||
if (restoredCircuit.shouldRestore) {
|
||||
console.log('[Velxio Bridge] Restoring circuit from draft');
|
||||
}
|
||||
velxioBridge.loadCircuit(restoredCircuit.content);
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Bridge] Restoring code from draft');
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: savedCode }]);
|
||||
} else if (data.initial_code_arduino) {
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: data.initial_code_arduino }]);
|
||||
const restoredCode = resolveRestoredContent(arduinoCodeKey, data.initial_code_arduino, isTeacher);
|
||||
if (restoredCode.content) {
|
||||
if (restoredCode.shouldRestore) {
|
||||
console.log('[Velxio Bridge] Restoring code from draft');
|
||||
}
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: restoredCode.content }]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,27 +211,23 @@ export function initVelxioBridge(
|
|||
lockComponents: true
|
||||
}, '*');
|
||||
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
const restoredCircuit = resolveRestoredContent(arduinoCircuitKey, data.velxio_circuit, isTeacher);
|
||||
if (restoredCircuit.content) {
|
||||
try {
|
||||
console.log('[Velxio Fallback] Restoring circuit from draft');
|
||||
const circuitData = JSON.parse(savedCircuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
} else if (data.velxio_circuit) {
|
||||
try {
|
||||
const circuitData = JSON.parse(data.velxio_circuit);
|
||||
if (restoredCircuit.shouldRestore) {
|
||||
console.log('[Velxio Fallback] Restoring circuit from draft');
|
||||
}
|
||||
const circuitData = JSON.parse(restoredCircuit.content);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Fallback] Restoring code from draft');
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: savedCode }] }, '*');
|
||||
} else if (data.initial_code_arduino) {
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: data.initial_code_arduino }] }, '*');
|
||||
const restoredCode = resolveRestoredContent(arduinoCodeKey, data.initial_code_arduino, isTeacher);
|
||||
if (restoredCode.content) {
|
||||
if (restoredCode.shouldRestore) {
|
||||
console.log('[Velxio Fallback] Restoring code from draft');
|
||||
}
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: restoredCode.content }] }, '*');
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin or not ready yet */ }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { page } from '$app/stores';
|
|||
import { get } from 'svelte/store';
|
||||
import { pickDefaultTab } from '$services/lesson-tabs';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { auth, authLoggedIn, authToken } from '$stores/auth';
|
||||
import { auth, authLoggedIn, authToken, authIsTeacher } from '$stores/auth';
|
||||
import { lessonContext } from '$stores/lessonContext';
|
||||
import { compileCode, submitLessonProgress, lessonProgressBeacon, type LessonProgressPayload } from '$services/api';
|
||||
import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity';
|
||||
|
|
@ -136,28 +136,32 @@ export class LessonManager {
|
|||
};
|
||||
mql.addEventListener('change', handler);
|
||||
|
||||
// Auto-save Velxio state periodically
|
||||
// Auto-save Velxio state periodically (only for logged-in students, not teachers)
|
||||
$effect(() => {
|
||||
if (this.velxioReady && get(authLoggedIn) && !this.showSolution) {
|
||||
if (this.velxioReady && get(authLoggedIn) && !get(authIsTeacher) && !this.showSolution) {
|
||||
const interval = setInterval(() => {
|
||||
const state = getVelxioState(this.velxioIframe);
|
||||
if (!state) return;
|
||||
|
||||
let changed = false;
|
||||
|
||||
// 1. Source Code
|
||||
// 1. Source Code: only save if modified from initial code
|
||||
const initialCode = this.data?.initial_code_arduino ?? '';
|
||||
const savedCode = localStorage.getItem(this.arduinoCodeKey);
|
||||
if (state.code && state.code !== savedCode) {
|
||||
if (state.code && state.code !== initialCode && state.code !== savedCode) {
|
||||
console.log('[Velxio Auto-save] Saving code changes (Zustand)');
|
||||
localStorage.setItem(this.arduinoCodeKey, state.code);
|
||||
localStorage.setItem(this.arduinoCodeKey + '_base', initialCode);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 2. Circuit (Diagram + Wires)
|
||||
// 2. Circuit (Diagram + Wires): only save if modified from initial circuit
|
||||
const initialCircuit = this.data?.velxio_circuit ?? '';
|
||||
const savedCircuit = localStorage.getItem(this.arduinoCircuitKey);
|
||||
if (state.circuit && state.circuit !== savedCircuit) {
|
||||
if (state.circuit && state.circuit !== initialCircuit && state.circuit !== savedCircuit) {
|
||||
console.log('[Velxio Auto-save] Saving circuit changes (Zustand)');
|
||||
localStorage.setItem(this.arduinoCircuitKey, state.circuit);
|
||||
localStorage.setItem(this.arduinoCircuitKey + '_base', initialCircuit);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
|
@ -615,7 +619,9 @@ export class LessonManager {
|
|||
Object.assign(this.circuitOut, this.freshOutput());
|
||||
} else if (this.activeTab === 'velxio') {
|
||||
localStorage.removeItem(this.arduinoCodeKey);
|
||||
localStorage.removeItem(this.arduinoCodeKey + '_base');
|
||||
localStorage.removeItem(this.arduinoCircuitKey);
|
||||
localStorage.removeItem(this.arduinoCircuitKey + '_base');
|
||||
if (this.data.initial_code_arduino) {
|
||||
this.velxioBridge?.loadCode([{ name: 'sketch.ino', content: this.data.initial_code_arduino }]);
|
||||
}
|
||||
|
|
@ -688,7 +694,8 @@ export class LessonManager {
|
|||
error: '',
|
||||
success: null
|
||||
});
|
||||
}
|
||||
},
|
||||
get(authIsTeacher)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@
|
|||
<button class="panel-btn save-btn" onclick={() => handleSaveAction()} disabled={mgr.saving || (!mgr.dirty && mgr.activeTab !== 'velxio')} title="Simpan">
|
||||
{mgr.saving ? '⏳' : '💾'}
|
||||
</button>
|
||||
<button class="panel-btn publish-btn" onclick={() => handlePublishAction()} disabled={mgr.publishing || !mgr.draftId} title="Publish">
|
||||
<button class="panel-btn publish-btn" onclick={() => handlePublishAction()} disabled={mgr.publishing || mgr.saving} title="Publish">
|
||||
{mgr.publishing ? '⏳' : '🚀'}
|
||||
</button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from flask import Blueprint, jsonify, request
|
|||
from config import ASSETS_DIR, CONTENT_DIR
|
||||
from services import repositories
|
||||
from services.database import SessionLocal
|
||||
from services.lesson_service import _render_markdown_string
|
||||
from services.lesson_service import _render_markdown_string, invalidate_lesson_caches
|
||||
from services.teacher_auth import check_origin, teacher_user_from_cookie
|
||||
|
||||
content_editor_bp = Blueprint("content_editor", __name__, url_prefix="/content")
|
||||
|
|
@ -215,6 +215,7 @@ def publish(draft_id):
|
|||
}), 500
|
||||
|
||||
repositories.mark_draft_published(db, draft=draft)
|
||||
invalidate_lesson_caches(abs_path)
|
||||
return jsonify({"success": True, "published_path": target_path})
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -347,6 +348,7 @@ def create_file():
|
|||
f.write(template)
|
||||
except PermissionError:
|
||||
return jsonify({"success": False, "message": "Permission denied — tidak bisa menulis file"}), 403
|
||||
invalidate_lesson_caches(abs_path)
|
||||
return jsonify({"success": True, "path": rel})
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -396,6 +398,8 @@ def rename_entry():
|
|||
|
||||
if root == "content":
|
||||
repositories.retarget_drafts(db, old_prefix=old_rel, new_prefix=new_rel)
|
||||
invalidate_lesson_caches(old_abs)
|
||||
invalidate_lesson_caches(new_abs)
|
||||
return jsonify({"success": True, "path": new_rel})
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -444,6 +448,7 @@ def delete_entry():
|
|||
|
||||
if root == "content":
|
||||
repositories.drop_drafts_under(db, path=rel)
|
||||
invalidate_lesson_caches(abs_path)
|
||||
return jsonify({"success": True})
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -63,25 +63,42 @@ def _read_md_cached(path):
|
|||
with _file_cache_lock:
|
||||
_file_cache[path] = {'content': content, 'mtime': current_mtime}
|
||||
|
||||
# If this is the root home.md, invalidate downstream caches
|
||||
if os.path.abspath(path) == _HOME_MD_PATH:
|
||||
find_lesson_file.cache_clear()
|
||||
get_lessons.cache_clear()
|
||||
get_lesson_names.cache_clear()
|
||||
get_lessons_with_learning_objectives.cache_clear()
|
||||
with _markdown_lock:
|
||||
_markdown_cache.clear()
|
||||
|
||||
# If this is a sub-home.md, invalidate its parsed data cache so edits to
|
||||
# the file (new lessons, reorder) show up without restarting the app.
|
||||
if os.path.basename(path) == 'sub-home.md':
|
||||
with _sub_home_cache_lock:
|
||||
_sub_home_cache.clear()
|
||||
find_lesson_file.cache_clear()
|
||||
# If this is the root home.md or a sub-home.md, invalidate downstream caches
|
||||
if os.path.abspath(path) == _HOME_MD_PATH or os.path.basename(path) == 'sub-home.md':
|
||||
invalidate_lesson_caches(path)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def invalidate_lesson_caches(file_path=None):
|
||||
"""Invalidate all in-memory caches related to lessons, files, and navigation.
|
||||
|
||||
If file_path is provided, specific file entries in _markdown_cache and _file_cache
|
||||
are removed. LRU caches and sub_home cache are cleared unconditionally.
|
||||
"""
|
||||
find_lesson_file.cache_clear()
|
||||
get_lessons.cache_clear()
|
||||
get_lesson_names.cache_clear()
|
||||
get_lessons_with_learning_objectives.cache_clear()
|
||||
|
||||
with _markdown_lock:
|
||||
if file_path:
|
||||
_markdown_cache.pop(file_path, None)
|
||||
_markdown_cache.pop(os.path.abspath(file_path), None)
|
||||
else:
|
||||
_markdown_cache.clear()
|
||||
|
||||
with _file_cache_lock:
|
||||
if file_path:
|
||||
_file_cache.pop(file_path, None)
|
||||
_file_cache.pop(os.path.abspath(file_path), None)
|
||||
else:
|
||||
_file_cache.clear()
|
||||
|
||||
with _sub_home_cache_lock:
|
||||
_sub_home_cache.clear()
|
||||
|
||||
|
||||
def _read_home_md():
|
||||
"""Read root home.md — thin wrapper for backwards compatibility."""
|
||||
path = os.path.join(CONTENT_DIR, "home.md")
|
||||
|
|
|
|||
Loading…
Reference in New Issue