fix(database): rewrite table view in vanilla JS (Alpine 3.14.9 compat)
Alpine 3.14.9 evaluates x-data expressions before Alpine.data() registration, causing 'pages is not defined', 'emptyRows is not defined', etc. Solution: pure vanilla JS DOM rendering (same pattern as Library fix). - _database_table.html: static DOM with IDs only, no Alpine directives - _database_table_scripts.html: vanilla JS state, render, cell editing, modals - Removes all x-data, x-for, x-show, x-model from database table
This commit is contained in:
@@ -1,57 +1,25 @@
|
||||
<!-- ═══════════ Database Table Scripts ═══════════ -->
|
||||
<!-- ═══════════ Database Table Scripts — Vanilla JS (no Alpine) ═══════════ -->
|
||||
<script>
|
||||
// Server-rendered initial data
|
||||
const COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
|
||||
const PAGE_ID = window.__DB_PAGE_ID || 0;
|
||||
(function() {
|
||||
'use strict';
|
||||
const COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
|
||||
const PAGE_ID = window.__DB_PAGE_ID || 0;
|
||||
|
||||
Alpine.data('databaseTable', () => ({
|
||||
// State
|
||||
pages: [],
|
||||
properties: [],
|
||||
addViewMenuOpen: false,
|
||||
propModalOpen: false,
|
||||
newPropName: '',
|
||||
newPropType: 'text',
|
||||
editingCell: null,
|
||||
renameOpen: false,
|
||||
renameTitle: '',
|
||||
renamePageId: null,
|
||||
dragIdx: null,
|
||||
// ── State ──
|
||||
const state = {
|
||||
properties: [],
|
||||
pages: [],
|
||||
editingCell: null // {pageId, propId, input}
|
||||
};
|
||||
|
||||
// ── Init ──
|
||||
async init(el) {
|
||||
{% if collection_data %}
|
||||
// Preload from server
|
||||
this.properties = {{ collection_data.properties | tojson }};
|
||||
this.pages = ({{ collection_data.pages | tojson }}).map(p => ({...p, _hover: false, _selected: false}));
|
||||
{% else %}
|
||||
await this.loadData();
|
||||
{% endif %}
|
||||
console.log('[DB-Table] Init: collection_id=' + COLLECTION_ID, this.properties.length + ' props, ' + this.pages.length + ' pages');
|
||||
},
|
||||
// ── Icons helper (mirrors fd_icon on server side) ──
|
||||
const propTypeIcons = {
|
||||
title: 'Aa', text: 'Aa', number: '#', select: '☰',
|
||||
multi_select: '☰', date: '📅', checkbox: '☑',
|
||||
url: '🔗', email: '✉', phone: '📞'
|
||||
};
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const r = await fetch('/api/collections/' + COLLECTION_ID + '/table-data');
|
||||
const d = await r.json();
|
||||
this.properties = d.properties || [];
|
||||
this.pages = (d.pages || []).map(p => ({...p, _hover: false, _selected: false}));
|
||||
} catch(e) {
|
||||
console.error('[DB-Table] loadData error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Helpers ──
|
||||
propTypeIcon(type) {
|
||||
const icons = {
|
||||
title: 'Aa', text: 'Aa', number: '#', select: '☰',
|
||||
multi_select: '☰', date: '📅', checkbox: '☑',
|
||||
url: '🔗', email: '✉', phone: '📞'
|
||||
};
|
||||
return icons[type] || 'Aa';
|
||||
},
|
||||
|
||||
cellValue(cpage, prop) {
|
||||
function cellValue(cpage, prop) {
|
||||
if (!cpage.property_values_json) return '';
|
||||
let pv;
|
||||
try {
|
||||
@@ -61,21 +29,221 @@ Alpine.data('databaseTable', () => ({
|
||||
} catch(e) { return ''; }
|
||||
const val = pv[String(prop.id)];
|
||||
if (val === undefined || val === null) {
|
||||
return prop.prop_type === 'title' ? cpage.title : '';
|
||||
return prop.prop_type === 'title' ? (cpage.title || '') : '';
|
||||
}
|
||||
if (Array.isArray(val)) return val.join(', ');
|
||||
return String(val);
|
||||
},
|
||||
}
|
||||
|
||||
emptyRows() {
|
||||
// Aim for 5 visible rows total (1 New Page + data rows + empty)
|
||||
const dataRows = this.pages.length;
|
||||
const needed = Math.max(0, 4 - dataRows); // 4 data rows + 1 New = 5
|
||||
return Array.from({length: needed}, (_, i) => i);
|
||||
},
|
||||
// ── Render ──
|
||||
function renderTable() {
|
||||
const thead = document.getElementById('db-thead');
|
||||
const tbody = document.getElementById('db-tbody');
|
||||
if (!thead || !tbody) return;
|
||||
|
||||
// ── Actions ──
|
||||
async addNewPage() {
|
||||
const props = state.properties;
|
||||
const pages = state.pages;
|
||||
|
||||
// Header
|
||||
let h = '<tr class="db-header-row">';
|
||||
h += '<th class="db-th db-th-handle" style="width:32px"></th>';
|
||||
for (const prop of props) {
|
||||
h += '<th class="db-th db-th-property"><div class="db-th-content">';
|
||||
h += '<span class="db-prop-type-icon">' + (propTypeIcons[prop.prop_type] || 'Aa') + '</span>';
|
||||
h += '<span class="db-prop-name">' + escHtml(prop.name) + '</span>';
|
||||
h += '</div></th>';
|
||||
}
|
||||
h += '<th class="db-th db-th-add-prop"><button id="db-add-prop-btn" class="db-add-prop-btn">+ Add property</button></th>';
|
||||
h += '</tr>';
|
||||
thead.innerHTML = h;
|
||||
|
||||
// Bind Add Property
|
||||
const addBtn = document.getElementById('db-add-prop-btn');
|
||||
if (addBtn) addBtn.onclick = showPropModal;
|
||||
|
||||
// Body
|
||||
let b = '';
|
||||
// New Page row
|
||||
b += '<tr class="db-row db-row-new">';
|
||||
b += '<td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
|
||||
b += '<td class="db-td db-td-name" colspan="' + Math.max(1, props.length + 1) + '">';
|
||||
b += '<button id="db-new-page-btn" class="db-new-page-btn">New page</button>';
|
||||
b += '</td></tr>';
|
||||
|
||||
// Data rows
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const cp = pages[i];
|
||||
b += '<tr class="db-row" data-page-id="' + cp.id + '" data-idx="' + i + '">';
|
||||
// Drag handle
|
||||
b += '<td class="db-td db-td-handle">';
|
||||
b += '<span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span>';
|
||||
b += '</td>';
|
||||
// Cells
|
||||
for (const prop of props) {
|
||||
const val = cellValue(cp, prop) || '\u2014';
|
||||
const cellId = 'cell-' + cp.id + '-' + prop.id;
|
||||
b += '<td class="db-td db-td-cell">';
|
||||
b += '<div class="db-cell-content" id="' + cellId + '" data-page-id="' + cp.id + '" data-prop-id="' + prop.id + '">';
|
||||
b += '<span class="db-cell-value">' + escHtml(val) + '</span>';
|
||||
b += '</div></td>';
|
||||
}
|
||||
// Open button (hover only)
|
||||
b += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden">';
|
||||
b += '<button class="db-open-btn db-open-page-btn" data-page-id="' + cp.id + '" title="Open">↗</button>';
|
||||
b += '</td>';
|
||||
b += '</tr>';
|
||||
}
|
||||
|
||||
// Empty rows (fill to 5 total: 1 New Page + data + empty)
|
||||
const emptyNeeded = Math.max(0, 4 - pages.length);
|
||||
for (let i = 0; i < emptyNeeded; i++) {
|
||||
b += '<tr class="db-row db-row-empty">';
|
||||
b += '<td class="db-td db-td-handle"></td>';
|
||||
for (let j = 0; j < props.length; j++) {
|
||||
b += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
|
||||
}
|
||||
b += '<td class="db-td db-td-open"></td>';
|
||||
b += '</tr>';
|
||||
}
|
||||
|
||||
tbody.innerHTML = b;
|
||||
|
||||
// Bind events after render
|
||||
bindTableEvents();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function bindTableEvents() {
|
||||
const tbody = document.getElementById('db-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// New Page button
|
||||
const newBtn = document.getElementById('db-new-page-btn');
|
||||
if (newBtn) newBtn.onclick = addNewPage;
|
||||
|
||||
// Cell click → start editing
|
||||
tbody.querySelectorAll('.db-cell-content').forEach(el => {
|
||||
el.onclick = function(e) {
|
||||
if (state.editingCell) return;
|
||||
const pageId = parseInt(this.dataset.pageId);
|
||||
const propId = parseInt(this.dataset.propId);
|
||||
startEditCell(pageId, propId, this);
|
||||
};
|
||||
});
|
||||
|
||||
// Open buttons
|
||||
tbody.querySelectorAll('.db-open-page-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
const pageId = parseInt(this.dataset.pageId);
|
||||
window.location.href = '/pages/' + pageId;
|
||||
};
|
||||
});
|
||||
|
||||
// Row hover effects
|
||||
tbody.querySelectorAll('.db-row').forEach(row => {
|
||||
row.onmouseenter = function() {
|
||||
this.querySelectorAll('.db-hover-only').forEach(el => { el.style.visibility = 'visible'; });
|
||||
};
|
||||
row.onmouseleave = function() {
|
||||
this.querySelectorAll('.db-hover-only').forEach(el => { el.style.visibility = 'hidden'; });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cell Editing ──
|
||||
function startEditCell(pageId, propId, cellEl) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
const prop = state.properties.find(p => p.id === propId);
|
||||
if (!cp || !prop) return;
|
||||
|
||||
const span = cellEl.querySelector('.db-cell-value');
|
||||
const currentVal = cellValue(cp, prop);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'db-cell-input';
|
||||
input.value = currentVal;
|
||||
input.style.cssText = 'width:100%;padding:3px 6px;border:1px solid #2383E2;border-radius:3px;font-size:14px;font-family:var(--font-sans);color:var(--text-primary);background:var(--bg-primary);outline:none;box-sizing:border-box;';
|
||||
|
||||
cellEl.innerHTML = '';
|
||||
cellEl.appendChild(input);
|
||||
cellEl.classList.add('db-cell-editing');
|
||||
input.focus();
|
||||
|
||||
state.editingCell = { pageId, propId, input, cellEl };
|
||||
|
||||
input.onkeydown = function(e) {
|
||||
if (e.key === 'Enter') { commitEditCell(); }
|
||||
else if (e.key === 'Escape') { cancelEditCell(); }
|
||||
};
|
||||
input.onblur = function() { commitEditCell(); };
|
||||
}
|
||||
|
||||
function commitEditCell() {
|
||||
if (!state.editingCell) return;
|
||||
const { pageId, propId, input, cellEl } = state.editingCell;
|
||||
const val = input.value;
|
||||
state.editingCell = null;
|
||||
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
const prop = state.properties.find(p => p.id === propId);
|
||||
if (!cp || !prop) return;
|
||||
|
||||
// Update local state
|
||||
let pv;
|
||||
try {
|
||||
pv = typeof cp.property_values_json === 'string'
|
||||
? JSON.parse(cp.property_values_json)
|
||||
: cp.property_values_json;
|
||||
} catch(e) { pv = {}; }
|
||||
pv[String(propId)] = val;
|
||||
cp.property_values_json = pv;
|
||||
if (prop.prop_type === 'title') { cp.title = val; }
|
||||
|
||||
// Re-render cell
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
cellEl.innerHTML = '<span class="db-cell-value">' + escHtml(val || '\u2014') + '</span>';
|
||||
cellEl.onclick = function(e) {
|
||||
if (state.editingCell) return;
|
||||
startEditCell(pageId, propId, cellEl);
|
||||
};
|
||||
|
||||
// Persist to server
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/db/pages/' + pageId + '/api', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf[1] : ''
|
||||
},
|
||||
body: JSON.stringify({ title: cp.title, properties: pv })
|
||||
}).catch(function(e) { console.error('[DB] commitEditCell error:', e); });
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
if (!state.editingCell) return;
|
||||
const { pageId, propId, cellEl } = state.editingCell;
|
||||
state.editingCell = null;
|
||||
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
const prop = state.properties.find(p => p.id === propId);
|
||||
if (!cp || !prop) return;
|
||||
const val = cellValue(cp, prop);
|
||||
|
||||
cellEl.classList.remove('db-cell-editing');
|
||||
cellEl.innerHTML = '<span class="db-cell-value">' + escHtml(val || '\u2014') + '</span>';
|
||||
cellEl.onclick = function(e) {
|
||||
if (state.editingCell) return;
|
||||
startEditCell(pageId, propId, cellEl);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Add New Page ──
|
||||
async function addNewPage() {
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try {
|
||||
const r = await fetch('/api/collections/' + COLLECTION_ID + '/pages', {
|
||||
@@ -84,26 +252,40 @@ Alpine.data('databaseTable', () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf[1] : ''
|
||||
},
|
||||
body: JSON.stringify({title: 'New page'})
|
||||
body: JSON.stringify({ title: 'New page' })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.id) {
|
||||
this.pages.push({...d, _hover: false, _selected: false});
|
||||
console.log('[DB-Table] New page created:', d.id);
|
||||
state.pages.push({
|
||||
id: d.id, title: d.title, position: d.position,
|
||||
icon: 'file', collection_id: COLLECTION_ID,
|
||||
property_values_json: {}
|
||||
});
|
||||
renderTable();
|
||||
console.log('[DB] New page created:', d.id);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[DB-Table] addNewPage error:', e);
|
||||
console.error('[DB] addNewPage error:', e);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async addProperty() {
|
||||
this.propModalOpen = true;
|
||||
this.newPropName = '';
|
||||
this.newPropType = 'text';
|
||||
},
|
||||
// ── Property Modal ──
|
||||
function showPropModal() {
|
||||
document.getElementById('db-prop-modal').style.display = 'flex';
|
||||
document.getElementById('db-prop-name-input').value = '';
|
||||
document.getElementById('db-prop-name-input').focus();
|
||||
}
|
||||
|
||||
function hidePropModal() {
|
||||
document.getElementById('db-prop-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function createProperty() {
|
||||
const nameInput = document.getElementById('db-prop-name-input');
|
||||
const typeSelect = document.getElementById('db-prop-type-select');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
async createProperty() {
|
||||
if (!this.newPropName.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try {
|
||||
const r = await fetch('/db/' + COLLECTION_ID + '/properties/api', {
|
||||
@@ -112,137 +294,73 @@ Alpine.data('databaseTable', () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf[1] : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.newPropName.trim(),
|
||||
prop_type: this.newPropType
|
||||
})
|
||||
body: JSON.stringify({ name: name, prop_type: typeSelect.value })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.id) {
|
||||
this.properties.push(d);
|
||||
this.propModalOpen = false;
|
||||
console.log('[DB-Table] Property created:', d.name);
|
||||
state.properties.push(d);
|
||||
renderTable();
|
||||
hidePropModal();
|
||||
console.log('[DB] Property created:', d.name);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[DB-Table] createProperty error:', e);
|
||||
console.error('[DB] createProperty error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Cell editing ──
|
||||
startEditCell(pageId, propId, event) {
|
||||
// Click on cell starts editing
|
||||
this.editingCell = {pageId, propId};
|
||||
this.$nextTick(() => {
|
||||
const input = this.$refs.cellInput;
|
||||
if (input) {
|
||||
if (Array.isArray(input)) input[0]?.focus();
|
||||
else input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async commitEditCell(cpage, prop) {
|
||||
if (!this.editingCell) return;
|
||||
const input = this.$refs.cellInput;
|
||||
const val = Array.isArray(input) ? input[0]?.value : input?.value;
|
||||
this.editingCell = null;
|
||||
|
||||
if (val === undefined) return;
|
||||
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try {
|
||||
// Update local state first
|
||||
let pv;
|
||||
try {
|
||||
pv = typeof cpage.property_values_json === 'string'
|
||||
? JSON.parse(cpage.property_values_json)
|
||||
: cpage.property_values_json;
|
||||
} catch(e) { pv = {}; }
|
||||
pv[String(prop.id)] = val;
|
||||
cpage.property_values_json = pv;
|
||||
|
||||
// If it's the title property, also update the title field
|
||||
if (prop.prop_type === 'title') {
|
||||
cpage.title = val;
|
||||
}
|
||||
|
||||
// Persist to server
|
||||
await fetch('/db/pages/' + cpage.id + '/api', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf[1] : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: cpage.title,
|
||||
properties: pv
|
||||
})
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('[DB-Table] commitEditCell error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
cancelEditCell() {
|
||||
this.editingCell = null;
|
||||
},
|
||||
|
||||
// ── Open page ──
|
||||
openPage(cpageId) {
|
||||
// Open the collection page as a regular page
|
||||
window.location.href = '/pages/' + cpageId;
|
||||
},
|
||||
|
||||
// ── Drag (placeholder) ──
|
||||
startDrag(idx, event) {
|
||||
this.dragIdx = idx;
|
||||
console.log('[DB-Table] Drag start:', idx);
|
||||
},
|
||||
|
||||
// ── Rename ──
|
||||
startRename(cpage) {
|
||||
this.renamePageId = cpage.id;
|
||||
this.renameTitle = cpage.title;
|
||||
this.renameOpen = true;
|
||||
this.$nextTick(() => {
|
||||
const input = this.$refs.renameInput;
|
||||
if (input) {
|
||||
if (Array.isArray(input)) input[0]?.focus();
|
||||
else input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async commitRename() {
|
||||
if (!this.renamePageId || !this.renameTitle.trim()) {
|
||||
this.renameOpen = false;
|
||||
return;
|
||||
}
|
||||
const cpage = this.pages.find(p => p.id === this.renamePageId);
|
||||
if (cpage) cpage.title = this.renameTitle.trim();
|
||||
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try {
|
||||
await fetch('/db/pages/' + this.renamePageId + '/api', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf[1] : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: this.renameTitle.trim()
|
||||
})
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('[DB-Table] rename error:', e);
|
||||
}
|
||||
this.renameOpen = false;
|
||||
this.renamePageId = null;
|
||||
},
|
||||
|
||||
// ── View switching (placeholder) ──
|
||||
switchView(type) {
|
||||
console.log('[DB-Table] Switch view to:', type);
|
||||
}
|
||||
}));
|
||||
|
||||
// ── View Dropdown ──
|
||||
function bindUI() {
|
||||
// Add view dropdown toggle
|
||||
document.getElementById('db-view-add-btn').onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
const dd = document.getElementById('db-add-view-dropdown');
|
||||
dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
document.addEventListener('click', function() {
|
||||
document.getElementById('db-add-view-dropdown').style.display = 'none';
|
||||
});
|
||||
|
||||
// Property modal
|
||||
document.getElementById('db-prop-modal-close').onclick = hidePropModal;
|
||||
document.getElementById('db-prop-cancel').onclick = hidePropModal;
|
||||
document.getElementById('db-prop-create').onclick = createProperty;
|
||||
document.getElementById('db-prop-modal').onclick = function(e) {
|
||||
if (e.target === this) hidePropModal();
|
||||
};
|
||||
document.getElementById('db-prop-name-input').onkeydown = function(e) {
|
||||
if (e.key === 'Enter') createProperty();
|
||||
};
|
||||
|
||||
// New button (top right)
|
||||
document.getElementById('db-new-btn').onclick = addNewPage;
|
||||
}
|
||||
|
||||
// ── Load Data ──
|
||||
async function loadData() {
|
||||
{% if collection_data %}
|
||||
// Preloaded from server
|
||||
state.properties = {{ collection_data.properties | tojson }};
|
||||
state.pages = {{ collection_data.pages | tojson }};
|
||||
{% else %}
|
||||
try {
|
||||
const r = await fetch('/api/collections/' + COLLECTION_ID + '/table-data');
|
||||
const d = await r.json();
|
||||
state.properties = d.properties || [];
|
||||
state.pages = d.pages || [];
|
||||
} catch(e) {
|
||||
console.error('[DB] loadData error:', e);
|
||||
}
|
||||
{% endif %}
|
||||
console.log('[DB-Table] Init: collection_id=' + COLLECTION_ID, state.properties.length + ' props, ' + state.pages.length + ' pages');
|
||||
renderTable();
|
||||
bindUI();
|
||||
}
|
||||
|
||||
// ── Start ──
|
||||
if (COLLECTION_ID > 0) {
|
||||
loadData();
|
||||
} else {
|
||||
console.warn('[DB-Table] No collection_id — cannot load database table');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user