feat(db): v5.3.0 inline databases, predefined templates & property validation
- 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:
@@ -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(); }
|
||||
|
||||
@@ -581,4 +581,43 @@
|
||||
></span>
|
||||
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
|
||||
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
|
||||
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
|
||||
@click.self="closeDbTemplatePicker()">
|
||||
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Create a database</h3>
|
||||
<button class="flowdeck-modal-close" @click="closeDbTemplatePicker()">×</button>
|
||||
</div>
|
||||
<div style="padding:12px 20px;overflow-y:auto;">
|
||||
<div x-show="dbTplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Loading templates…</div>
|
||||
<div x-show="!dbTplLoading">
|
||||
<!-- Blank -->
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(null)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;">📋</span>
|
||||
<div><div style="font-weight:600;color:var(--text,#fff);font-size:14px;">Blank database</div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);">Start from an empty database</div></div>
|
||||
</div>
|
||||
<template x-for="t in dbTemplates" :key="t.id">
|
||||
<div class="fd-dbtpl-card" @click="createDbFromTemplate(t.name)"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
|
||||
<span style="font-size:22px;" x-text="t.icon||'📋'"></span>
|
||||
<div style="flex:1;"><div style="font-weight:600;color:var(--text,#fff);font-size:14px;" x-text="t.name"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim,#999);" x-text="t.description||''"></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!dbTplLoading && !dbTemplates.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
|
||||
No templates available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
{id:'callout',name:'Callout',icon:'lightbulb'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
{id:'table',name:'Table',icon:'⊞'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]}];
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
@@ -871,26 +872,48 @@
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
|
||||
// ── v4.6.0: Get Started actions ──
|
||||
// ── v4.6.0 / v5.3.0: Get Started actions — Database (template picker) ──
|
||||
dbTplOpen:false, dbTplLoading:false, dbTemplates:[], dbTplTargetIdx:-1,
|
||||
async createDatabase(){
|
||||
this.openDbTemplatePicker(-1);
|
||||
},
|
||||
async openDbTemplatePicker(targetIdx){
|
||||
this.gsMoreOpen=false;
|
||||
this.dbTplOpen=true; this.dbTplTargetIdx=targetIdx; this.dbTplLoading=true;
|
||||
var self=this;
|
||||
try{
|
||||
const r=await fetch('/workspace/templates/database');
|
||||
const d=await r.json();
|
||||
self.dbTemplates=d.templates||[];
|
||||
}catch(e){ self.dbTemplates=[]; }
|
||||
self.dbTplLoading=false;
|
||||
self.$nextTick(()=>{ const f=document.getElementById('fd-dbtpl'); if(f) setTimeout(()=>{f.style.opacity='1';},10); });
|
||||
},
|
||||
closeDbTemplatePicker(){ this.dbTplOpen=false; },
|
||||
async createDbFromTemplate(tplName){
|
||||
const self=this;
|
||||
var name = tplName ? (tplName+' — '+new Date().toLocaleDateString('fr-FR')) : 'Database';
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
try{
|
||||
const name='Database';
|
||||
// Create collection via API
|
||||
const r=await fetch('/db/api',{
|
||||
const body={name:name, parent_page_id:this.pid};
|
||||
if(tplName) body.template=tplName;
|
||||
const r=await fetch('/db/inline/api',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
body:JSON.stringify({name:name})
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
const d=await r.json();
|
||||
if(d.id){
|
||||
// Add inline database embed block to the page
|
||||
this.blocks.push(this.mkB('embed','',{embed_type:'collection',collection_id:d.id}));
|
||||
this.render(); this.dirty=true; this.autoSave();
|
||||
this.showToast('Database created');
|
||||
const nb=this.mkB('embed','',{embed_type:'collection',collection_id:d.id});
|
||||
this.sync();
|
||||
const idx=this.dbTplTargetIdx>=0?this.dbTplTargetIdx:(this.blocks.length);
|
||||
this.blocks.splice(idx,0,nb);
|
||||
this.dbTplOpen=false;
|
||||
this.dirty=true; this.autoSave(); this.render();
|
||||
this.showToast(tplName?('Database created from template — '+tplName):'Database created');
|
||||
}else{
|
||||
this.showToast('Failed to create database');
|
||||
this.showToast(d.detail||'Failed to create database');
|
||||
}
|
||||
}catch(e){this.showToast('Error: '+e.message);}
|
||||
}catch(e){ this.showToast('Error: '+e.message); }
|
||||
},
|
||||
async createForm(){
|
||||
const name=prompt('Form name:','New Form');
|
||||
@@ -979,6 +1002,7 @@
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}}
|
||||
|
||||
Reference in New Issue
Block a user