This commit is contained in:
16
website/js/api-envelope.js
Normal file
16
website/js/api-envelope.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Unwrap the unified response envelope:
|
||||
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
||||
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
||||
* Also supports legacy format for backward compat.
|
||||
*/
|
||||
export function unwrap(json) {
|
||||
if (typeof json.ok === 'boolean') {
|
||||
if (json.ok) {
|
||||
const d = json.data || {};
|
||||
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
||||
}
|
||||
return { success: false, error: json.error?.message || 'Unknown error' };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { unwrap } from './api-envelope.js';
|
||||
|
||||
const API_BASE = '../api';
|
||||
|
||||
function getToken() {
|
||||
@ -32,23 +34,6 @@ async function apiFetch(endpoint, options = {}) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the unified response envelope:
|
||||
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
||||
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
||||
* Also supports legacy format for backward compat.
|
||||
*/
|
||||
function unwrap(json) {
|
||||
if (typeof json.ok === 'boolean') {
|
||||
if (json.ok) {
|
||||
const d = json.data || {};
|
||||
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
||||
}
|
||||
return { success: false, error: json.error?.message || 'Unknown error' };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function apiGet(endpoint) {
|
||||
const res = await apiFetch(endpoint);
|
||||
return unwrap(await res.json());
|
||||
|
||||
@ -7,21 +7,16 @@ import { exportPage } from './pages/export.js';
|
||||
import { usersPage } from './pages/users.js';
|
||||
import { assignmentsPage } from './pages/assignments.js';
|
||||
import { clientsPage } from './pages/clients.js';
|
||||
import {
|
||||
isLoggedIn,
|
||||
getRole,
|
||||
getUser,
|
||||
canEdit,
|
||||
authGuard as createAuthGuard,
|
||||
roleGuard as createRoleGuard,
|
||||
} from './auth-state.js';
|
||||
|
||||
// Auth state
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('qdb_token');
|
||||
}
|
||||
export function getRole() {
|
||||
return localStorage.getItem('qdb_role') || '';
|
||||
}
|
||||
export function getUser() {
|
||||
return localStorage.getItem('qdb_user') || '';
|
||||
}
|
||||
export function canEdit() {
|
||||
const r = getRole();
|
||||
return r === 'admin' || r === 'supervisor';
|
||||
}
|
||||
export { isLoggedIn, getRole, getUser, canEdit };
|
||||
|
||||
export function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
@ -37,10 +32,7 @@ export function showToast(message, type = 'info') {
|
||||
}
|
||||
|
||||
function authGuard(handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
return createAuthGuard(navigate, handler);
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
@ -70,11 +62,7 @@ function updateNav() {
|
||||
}
|
||||
|
||||
function roleGuard(roles, handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
return createRoleGuard(navigate, roles, handler);
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
||||
31
website/js/auth-state.js
Normal file
31
website/js/auth-state.js
Normal file
@ -0,0 +1,31 @@
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('qdb_token');
|
||||
}
|
||||
|
||||
export function getRole() {
|
||||
return localStorage.getItem('qdb_role') || '';
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
return localStorage.getItem('qdb_user') || '';
|
||||
}
|
||||
|
||||
export function canEdit() {
|
||||
const r = getRole();
|
||||
return r === 'admin' || r === 'supervisor';
|
||||
}
|
||||
|
||||
export function authGuard(navigate, handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
export function roleGuard(navigate, roles, handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
203
website/js/editor-utils.js
Normal file
203
website/js/editor-utils.js
Normal file
@ -0,0 +1,203 @@
|
||||
export const LAYOUT_TYPES = [
|
||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
|
||||
{ value: 'glass_scale_question', label: 'Glass Scale' },
|
||||
{ value: 'value_spinner', label: 'Value Spinner' },
|
||||
{ value: 'date_spinner', label: 'Date Spinner' },
|
||||
{ value: 'string_spinner', label: 'String Spinner' },
|
||||
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
||||
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
||||
{ value: 'last_page', label: 'Last Page' },
|
||||
];
|
||||
|
||||
export const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||||
|
||||
export function layoutSelectHTML(selected = 'radio_question', id = '') {
|
||||
return `<select ${id ? `id="${id}"` : ''} class="type-select">
|
||||
${LAYOUT_TYPES.map(t =>
|
||||
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
|
||||
).join('')}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
export function layoutLabel(value) {
|
||||
const t = LAYOUT_TYPES.find(t => t.value === value);
|
||||
return t ? t.label : value || 'unknown';
|
||||
}
|
||||
|
||||
export function parseConfig(q) {
|
||||
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
export function questionLocalIds(questions) {
|
||||
return questions.map(q => {
|
||||
const parts = q.questionID.split('__');
|
||||
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText };
|
||||
});
|
||||
}
|
||||
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export function configFormHTML(layout, config, prefix) {
|
||||
let html = '';
|
||||
switch (layout) {
|
||||
case 'radio_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Extra text key (optional)</label>
|
||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
|
||||
</div>`;
|
||||
break;
|
||||
case 'multi_check_box_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Min selections</label>
|
||||
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
||||
</div>`;
|
||||
break;
|
||||
case 'glass_scale_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Symptom keys (one per line)</label>
|
||||
<textarea id="${prefix}_symptoms" rows="5" style="font-family:monospace;font-size:.85rem">${(config.symptoms || []).join('\n')}</textarea>
|
||||
</div>`;
|
||||
break;
|
||||
case 'value_spinner':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Min</label>
|
||||
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max</label>
|
||||
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'date_spinner': {
|
||||
const notBefore = config.constraints?.notBefore || '';
|
||||
const notAfter = config.constraints?.notAfter || '';
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Not before (question key)</label>
|
||||
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Not after (question key)</label>
|
||||
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
case 'string_spinner':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Static options (one per line)</label>
|
||||
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||||
</div>`;
|
||||
break;
|
||||
case 'client_coach_code_question':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Hint 1 key</label>
|
||||
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Hint 2 key</label>
|
||||
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="coach_code">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'client_not_signed':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Text key 1</label>
|
||||
<input type="text" id="${prefix}_textKey1" value="${esc(config.textKey1 || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Text key 2</label>
|
||||
<input type="text" id="${prefix}_textKey2" value="${esc(config.textKey2 || '')}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Hint key</label>
|
||||
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'last_page':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Text key</label>
|
||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
return html ? `<div class="config-section">${html}</div>` : '';
|
||||
}
|
||||
|
||||
export function readConfigFromForm(layout, prefix) {
|
||||
const config = {};
|
||||
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
||||
|
||||
switch (layout) {
|
||||
case 'radio_question':
|
||||
if (val('textKey')) config.textKey = val('textKey');
|
||||
break;
|
||||
case 'multi_check_box_question': {
|
||||
const ms = parseInt(val('minSelection') || '0', 10);
|
||||
if (ms > 0) config.minSelection = ms;
|
||||
break;
|
||||
}
|
||||
case 'glass_scale_question': {
|
||||
const raw = val('symptoms');
|
||||
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (syms.length) config.symptoms = syms;
|
||||
break;
|
||||
}
|
||||
case 'value_spinner':
|
||||
config.range = {
|
||||
min: parseInt(val('rangeMin') || '0', 10),
|
||||
max: parseInt(val('rangeMax') || '100', 10),
|
||||
};
|
||||
break;
|
||||
case 'date_spinner': {
|
||||
const nb = val('notBefore');
|
||||
const na = val('notAfter');
|
||||
if (nb || na) {
|
||||
config.constraints = {};
|
||||
if (nb) config.constraints.notBefore = nb;
|
||||
if (na) config.constraints.notAfter = na;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'string_spinner': {
|
||||
const raw = val('stringOptions');
|
||||
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (opts.length) config.options = opts;
|
||||
break;
|
||||
}
|
||||
case 'client_coach_code_question':
|
||||
if (val('hint1')) config.hint1 = val('hint1');
|
||||
if (val('hint2')) config.hint2 = val('hint2');
|
||||
break;
|
||||
case 'client_not_signed':
|
||||
if (val('textKey1')) config.textKey1 = val('textKey1');
|
||||
if (val('textKey2')) config.textKey2 = val('textKey2');
|
||||
if (val('hint')) config.hint = val('hint');
|
||||
break;
|
||||
case 'last_page':
|
||||
if (val('textKey')) config.textKey = val('textKey');
|
||||
break;
|
||||
}
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
@ -1,27 +1,23 @@
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||||
import { canEdit, showToast } from '../app.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const LAYOUT_TYPES = [
|
||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
|
||||
{ value: 'glass_scale_question', label: 'Glass Scale' },
|
||||
{ value: 'value_spinner', label: 'Value Spinner' },
|
||||
{ value: 'date_spinner', label: 'Date Spinner' },
|
||||
{ value: 'string_spinner', label: 'String Spinner' },
|
||||
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
||||
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
||||
{ value: 'last_page', label: 'Last Page' },
|
||||
];
|
||||
|
||||
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||||
import {
|
||||
OPTION_TYPES,
|
||||
layoutSelectHTML,
|
||||
layoutLabel,
|
||||
parseConfig,
|
||||
questionLocalIds,
|
||||
configFormHTML,
|
||||
readConfigFromForm,
|
||||
esc,
|
||||
} from '../editor-utils.js';
|
||||
|
||||
let questionnaire = null;
|
||||
let questions = [];
|
||||
let isNew = false;
|
||||
let activeTab = 'questions';
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────
|
||||
// ── Entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function editorPage(params) {
|
||||
const app = document.getElementById('app');
|
||||
@ -66,195 +62,7 @@ export async function editorPage(params) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layout helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function layoutSelectHTML(selected = 'radio_question', id = '') {
|
||||
return `<select ${id ? `id="${id}"` : ''} class="type-select">
|
||||
${LAYOUT_TYPES.map(t =>
|
||||
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
|
||||
).join('')}
|
||||
</select>`;
|
||||
}
|
||||
|
||||
function layoutLabel(value) {
|
||||
const t = LAYOUT_TYPES.find(t => t.value === value);
|
||||
return t ? t.label : value || 'unknown';
|
||||
}
|
||||
|
||||
function parseConfig(q) {
|
||||
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
function questionLocalIds() {
|
||||
return questions.map(q => {
|
||||
const parts = q.questionID.split('__');
|
||||
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config form HTML for each layout type ────────────────────────────────
|
||||
|
||||
function configFormHTML(layout, config, prefix) {
|
||||
let html = '';
|
||||
switch (layout) {
|
||||
case 'radio_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Extra text key (optional)</label>
|
||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
|
||||
</div>`;
|
||||
break;
|
||||
case 'multi_check_box_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Min selections</label>
|
||||
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
||||
</div>`;
|
||||
break;
|
||||
case 'glass_scale_question':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Symptom keys (one per line)</label>
|
||||
<textarea id="${prefix}_symptoms" rows="5" style="font-family:monospace;font-size:.85rem">${(config.symptoms || []).join('\n')}</textarea>
|
||||
</div>`;
|
||||
break;
|
||||
case 'value_spinner':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Min</label>
|
||||
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max</label>
|
||||
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'date_spinner': {
|
||||
const notBefore = config.constraints?.notBefore || '';
|
||||
const notAfter = config.constraints?.notAfter || '';
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Not before (question key)</label>
|
||||
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Not after (question key)</label>
|
||||
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
case 'string_spinner':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Static options (one per line)</label>
|
||||
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||||
</div>`;
|
||||
break;
|
||||
case 'client_coach_code_question':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Hint 1 key</label>
|
||||
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Hint 2 key</label>
|
||||
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="coach_code">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'client_not_signed':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Text key 1</label>
|
||||
<input type="text" id="${prefix}_textKey1" value="${esc(config.textKey1 || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Text key 2</label>
|
||||
<input type="text" id="${prefix}_textKey2" value="${esc(config.textKey2 || '')}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Hint key</label>
|
||||
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}">
|
||||
</div>
|
||||
</div>`;
|
||||
break;
|
||||
case 'last_page':
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Text key</label>
|
||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
return html ? `<div class="config-section">${html}</div>` : '';
|
||||
}
|
||||
|
||||
function readConfigFromForm(layout, prefix) {
|
||||
const config = {};
|
||||
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
||||
|
||||
switch (layout) {
|
||||
case 'radio_question':
|
||||
if (val('textKey')) config.textKey = val('textKey');
|
||||
break;
|
||||
case 'multi_check_box_question': {
|
||||
const ms = parseInt(val('minSelection') || '0', 10);
|
||||
if (ms > 0) config.minSelection = ms;
|
||||
break;
|
||||
}
|
||||
case 'glass_scale_question': {
|
||||
const raw = val('symptoms');
|
||||
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (syms.length) config.symptoms = syms;
|
||||
break;
|
||||
}
|
||||
case 'value_spinner':
|
||||
config.range = {
|
||||
min: parseInt(val('rangeMin') || '0', 10),
|
||||
max: parseInt(val('rangeMax') || '100', 10),
|
||||
};
|
||||
break;
|
||||
case 'date_spinner': {
|
||||
const nb = val('notBefore');
|
||||
const na = val('notAfter');
|
||||
if (nb || na) {
|
||||
config.constraints = {};
|
||||
if (nb) config.constraints.notBefore = nb;
|
||||
if (na) config.constraints.notAfter = na;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'string_spinner': {
|
||||
const raw = val('stringOptions');
|
||||
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (opts.length) config.options = opts;
|
||||
break;
|
||||
}
|
||||
case 'client_coach_code_question':
|
||||
if (val('hint1')) config.hint1 = val('hint1');
|
||||
if (val('hint2')) config.hint2 = val('hint2');
|
||||
break;
|
||||
case 'client_not_signed':
|
||||
if (val('textKey1')) config.textKey1 = val('textKey1');
|
||||
if (val('textKey2')) config.textKey2 = val('textKey2');
|
||||
if (val('hint')) config.hint = val('hint');
|
||||
break;
|
||||
case 'last_page':
|
||||
if (val('textKey')) config.textKey = val('textKey');
|
||||
break;
|
||||
}
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
|
||||
// ── Main editor render ───────────────────────────────────────────────────
|
||||
// ── Main editor render (helpers in editor-utils.js) ─────────────────────
|
||||
|
||||
function renderEditor() {
|
||||
const container = document.getElementById('editorContent');
|
||||
@ -367,7 +175,7 @@ function formatJson(str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save questionnaire meta ──────────────────────────────────────────────
|
||||
// ── Save questionnaire meta ──────────────────────────────────────────────
|
||||
|
||||
async function saveMeta() {
|
||||
const name = document.getElementById('metaName').value.trim();
|
||||
@ -413,7 +221,7 @@ async function saveMeta() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add question form ────────────────────────────────────────────────────
|
||||
// ── Add question form ────────────────────────────────────────────────────
|
||||
|
||||
function showAddQuestionForm() {
|
||||
const wrapper = document.getElementById('addQuestionFormWrapper');
|
||||
@ -457,7 +265,7 @@ function showAddQuestionForm() {
|
||||
<div class="form-group" style="width:160px;margin-bottom:0">
|
||||
<select id="aq_opt_next">
|
||||
<option value="">(next in order)</option>
|
||||
${questionLocalIds().map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
||||
${questionLocalIds(questions).map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
|
||||
@ -588,7 +396,7 @@ function hideAddQuestionForm() {
|
||||
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
|
||||
}
|
||||
|
||||
// ── Questions list ───────────────────────────────────────────────────────
|
||||
// ── Questions list ───────────────────────────────────────────────────────
|
||||
|
||||
function renderQuestions() {
|
||||
const list = document.getElementById('questionList');
|
||||
@ -628,7 +436,7 @@ function renderQuestions() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Question body (expanded) ─────────────────────────────────────────────
|
||||
// ── Question body (expanded) ─────────────────────────────────────────────
|
||||
|
||||
function renderQuestionBody(q) {
|
||||
const body = document.getElementById(`qbody-${q.questionID}`);
|
||||
@ -734,7 +542,7 @@ function optionItemHTML(q, o, editable) {
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Add option form ──────────────────────────────────────────────────────
|
||||
// ── Add option form ──────────────────────────────────────────────────────
|
||||
|
||||
function showAddOptionForm(q) {
|
||||
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
|
||||
@ -743,7 +551,7 @@ function showAddOptionForm(q) {
|
||||
toggleBtn.style.display = 'none';
|
||||
wrapper.style.display = '';
|
||||
|
||||
const qIds = questionLocalIds();
|
||||
const qIds = questionLocalIds(questions);
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="inline-form-card" style="margin-top:8px">
|
||||
@ -813,12 +621,12 @@ function hideAddOptionForm(q) {
|
||||
if (toggleBtn) toggleBtn.style.display = '';
|
||||
}
|
||||
|
||||
// ── Edit option form ─────────────────────────────────────────────────────
|
||||
// ── Edit option form ─────────────────────────────────────────────────────
|
||||
|
||||
function showEditOptionForm(q, opt) {
|
||||
const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`);
|
||||
if (!li) return;
|
||||
const qIds = questionLocalIds();
|
||||
const qIds = questionLocalIds(questions);
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="inline-form-card" style="width:100%;padding:8px">
|
||||
@ -861,7 +669,7 @@ function showEditOptionForm(q, opt) {
|
||||
document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q));
|
||||
}
|
||||
|
||||
// ── Question CRUD ────────────────────────────────────────────────────────
|
||||
// ── Question CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
async function updateQuestion(questionID, fields) {
|
||||
try {
|
||||
@ -892,7 +700,7 @@ async function deleteQuestion(q) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Answer option CRUD ───────────────────────────────────────────────────
|
||||
// ── Answer option CRUD ───────────────────────────────────────────────────
|
||||
|
||||
async function updateOption(q, answerOptionID, fields) {
|
||||
try {
|
||||
@ -920,7 +728,7 @@ async function deleteOption(q, answerOptionID) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Translations tab ─────────────────────────────────────────────────────
|
||||
// ── Translations tab ─────────────────────────────────────────────────────
|
||||
|
||||
let transData = null;
|
||||
|
||||
@ -1169,7 +977,7 @@ function updateTransStats(entries, langCodes) {
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
||||
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
||||
|
||||
function initDragReorder() {
|
||||
const list = document.getElementById('questionList');
|
||||
@ -1264,9 +1072,3 @@ function getDragAfterElement(container, y, selector) {
|
||||
return closest;
|
||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
@ -1,17 +1,37 @@
|
||||
const routes = [];
|
||||
let currentCleanup = null;
|
||||
|
||||
/** @returns {{ regex: RegExp, paramNames: string[] }} */
|
||||
export function compileRoute(pattern) {
|
||||
const paramNames = [];
|
||||
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '/([^/]+)';
|
||||
});
|
||||
return { regex: new RegExp('^' + parts + '$'), paramNames };
|
||||
}
|
||||
|
||||
/** Match a path against a string route pattern; returns params or null. */
|
||||
export function matchRoute(pattern, path) {
|
||||
const { regex, paramNames } = compileRoute(pattern);
|
||||
const match = path.match(regex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const params = {};
|
||||
paramNames.forEach((name, i) => {
|
||||
params[name] = decodeURIComponent(match[i + 1]);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export function addRoute(pattern, handler) {
|
||||
let regex;
|
||||
const paramNames = [];
|
||||
let paramNames = [];
|
||||
if (pattern instanceof RegExp) {
|
||||
regex = pattern;
|
||||
} else {
|
||||
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '/([^/]+)';
|
||||
});
|
||||
regex = new RegExp('^' + parts + '$');
|
||||
({ regex, paramNames } = compileRoute(pattern));
|
||||
}
|
||||
routes.push({ regex, paramNames, handler });
|
||||
}
|
||||
|
||||
13
website/package.json
Normal file
13
website/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "nat-as-website",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^25.0.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
28
website/tests/unit/api-envelope.test.js
Normal file
28
website/tests/unit/api-envelope.test.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unwrap } from '../../js/api-envelope.js';
|
||||
|
||||
describe('unwrap', () => {
|
||||
it('unwraps ok:true object data', () => {
|
||||
const r = unwrap({ ok: true, data: { token: 'abc', role: 'admin' } });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.token).toBe('abc');
|
||||
expect(r.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('unwraps ok:true array data as items', () => {
|
||||
const r = unwrap({ ok: true, data: [{ id: 1 }] });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.items).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('unwraps ok:false errors', () => {
|
||||
const r = unwrap({ ok: false, error: { code: 'X', message: 'Failed' } });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toBe('Failed');
|
||||
});
|
||||
|
||||
it('passes through legacy body', () => {
|
||||
const legacy = { success: true, questionnaires: [] };
|
||||
expect(unwrap(legacy)).toBe(legacy);
|
||||
});
|
||||
});
|
||||
41
website/tests/unit/auth-state.test.js
Normal file
41
website/tests/unit/auth-state.test.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { canEdit, isLoggedIn, getRole, authGuard, roleGuard } from '../../js/auth-state.js';
|
||||
|
||||
describe('auth-state', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('canEdit allows admin and supervisor only', () => {
|
||||
localStorage.setItem('qdb_role', 'admin');
|
||||
expect(canEdit()).toBe(true);
|
||||
localStorage.setItem('qdb_role', 'supervisor');
|
||||
expect(canEdit()).toBe(true);
|
||||
localStorage.setItem('qdb_role', 'coach');
|
||||
expect(canEdit()).toBe(false);
|
||||
});
|
||||
|
||||
it('isLoggedIn checks token', () => {
|
||||
expect(isLoggedIn()).toBe(false);
|
||||
localStorage.setItem('qdb_token', 't');
|
||||
expect(isLoggedIn()).toBe(true);
|
||||
});
|
||||
|
||||
it('authGuard redirects when logged out', () => {
|
||||
const navigate = vi.fn();
|
||||
const handler = vi.fn();
|
||||
const guarded = authGuard(navigate, handler);
|
||||
guarded({});
|
||||
expect(navigate).toHaveBeenCalledWith('#/login');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('roleGuard redirects coach from admin routes', () => {
|
||||
localStorage.setItem('qdb_token', 't');
|
||||
localStorage.setItem('qdb_role', 'coach');
|
||||
const navigate = vi.fn();
|
||||
const handler = vi.fn();
|
||||
roleGuard(navigate, ['admin', 'supervisor'], handler)({});
|
||||
expect(navigate).toHaveBeenCalledWith('#/');
|
||||
});
|
||||
});
|
||||
39
website/tests/unit/editor-utils.test.js
Normal file
39
website/tests/unit/editor-utils.test.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseConfig,
|
||||
questionLocalIds,
|
||||
layoutLabel,
|
||||
readConfigFromForm,
|
||||
} from '../../js/editor-utils.js';
|
||||
|
||||
describe('editor-utils', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('parseConfig parses JSON', () => {
|
||||
expect(parseConfig({ configJson: '{"textKey":"a"}' })).toEqual({ textKey: 'a' });
|
||||
expect(parseConfig({ configJson: 'not-json' })).toEqual({});
|
||||
});
|
||||
|
||||
it('questionLocalIds extracts short ids', () => {
|
||||
const ids = questionLocalIds([
|
||||
{ questionID: 'qn__q1', defaultText: 'Q1' },
|
||||
{ questionID: 'qn__q2', defaultText: 'Q2' },
|
||||
]);
|
||||
expect(ids.map(i => i.localId)).toEqual(['q1', 'q2']);
|
||||
});
|
||||
|
||||
it('layoutLabel resolves known layouts', () => {
|
||||
expect(layoutLabel('radio_question')).toContain('Radio');
|
||||
});
|
||||
|
||||
it('readConfigFromForm round-trips value_spinner', () => {
|
||||
document.body.innerHTML = `
|
||||
<input id="pfx_rangeMin" value="1" />
|
||||
<input id="pfx_rangeMax" value="10" />
|
||||
`;
|
||||
const json = readConfigFromForm('value_spinner', 'pfx');
|
||||
expect(JSON.parse(json)).toEqual({ range: { min: 1, max: 10 } });
|
||||
});
|
||||
});
|
||||
33
website/tests/unit/router.test.js
Normal file
33
website/tests/unit/router.test.js
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { navigate, currentHash, matchRoute } from '../../js/router.js';
|
||||
|
||||
|
||||
|
||||
describe('router', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
window.location.hash = '';
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('currentHash defaults to / when empty', () => {
|
||||
|
||||
window.location.hash = '';
|
||||
|
||||
expect(currentHash()).toBe('/');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('currentHash strips leading hash', () => {
|
||||
|
||||
window.location.hash = '#/dashboard';
|
||||
|
||||
expect(currentHash()).toBe('/dashboard');
|
||||
|
||||
});
|
||||
8
website/vitest.config.js
Normal file
8
website/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/unit/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user