feat(db): v5.3.0 inline databases, predefined templates & property validation
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Skipped

- Slash command /database (DATA group) -> template picker -> inline DB
  embed block rendered by FlowDeckDB. 'Get Started > Database' uses it too.
- 6 seeded database templates (CRM, Project tracker, Task list, Content
  calendar, Meeting notes, Reading list) with icon + schema; new service
  app/services/db_templates.py (materialize_properties, create_from_template).
- POST /db/api and /db/inline/api accept 'template' and materialize
  collection_properties. database_templates gets an icon column (migration v4).
- Property validation: collection_properties.validation_json (migration v4);
  validate_property_rule() in property_types (required/unique/min/max/
  min_length/max_length); enforced in create/update page API (400 + message,
  unique excludes current row); property API accepts 'validation'.
- UI: add-property modal exposes Required/Unique/Min/Max; cell edit shows
  validation errors (red outline + toast) and reverts.
- VERSION + app.main -> 5.1.0; CHANGELOG + ROADMAP updated.
- +9 tests (tests/test_db_advanced.py). 271 passed.
This commit is contained in:
2026-09-07 00:53:13 -04:00
parent 0c271d5972
commit e4b196a528
13 changed files with 759 additions and 51 deletions
+42 -2
View File
@@ -1,4 +1,5 @@
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
<style>.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}</style>
<script>
(function() {
'use strict';
@@ -206,7 +207,31 @@
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})}).catch(function(e){});
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})})
.then(function(r){
if(!r.ok){
return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
}
cellEl.classList.remove('db-cell-invalid');
return r.json();
})
.catch(function(err){
// Validation failed (400) → show message + revert the cell.
const msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
if(err && err.status===400){
cellEl.classList.add('db-cell-invalid');
cellEl.title = msg;
}
if(typeof window.showToast==='function') window.showToast('⚠ '+msg,'error');
else alert('⚠ '+msg);
// Revert to the stored value.
const stored = pv[String(propId)];
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(stored||'\u2014')+'</span>';
if (prop.prop_type === 'title') {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
});
}
function cancelEditCell() {
@@ -246,6 +271,14 @@
'<div class="db-modal-body" style="padding:16px">'+
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="date">Date</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option></select></div>'+
'<div style="display:flex;gap:16px;margin-bottom:12px">'+
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label>'+
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label>'+
'</div>'+
'<div style="display:flex;gap:10px;margin-bottom:12px">'+
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
'</div>'+
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
'</div></div>';
document.body.appendChild(modal);
@@ -261,10 +294,17 @@
var name = nameInput.value.trim();
if(!name) return;
var type = modal.querySelector('.db-prop-type-inline').value;
var validation = {};
if(modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
if(modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
var min = modal.querySelector('.db-prop-min-inline').value;
var max = modal.querySelector('.db-prop-max-inline').value;
if(min!=='') validation.min = Number(min);
if(max!=='') validation.max = Number(max);
try {
var r = await fetch('/db/'+collectionId+'/properties/api',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body:JSON.stringify({name:name,prop_type:type})
body:JSON.stringify({name:name,prop_type:type,validation:validation})
});
var d = await r.json();
if(d.id){ state.properties.push(d); render(); modal.remove(); }