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
+41 -1
View File
@@ -1,6 +1,46 @@
# Changelog — FlowDeck
## v5.0.0 (2026-09-06) — Command Palette & Migrations versionnées
## v5.1.0 (2026-09-06) — Database Avancée : inline DB, templates prédéfinis & validation
> Livre la **v5.3.0** du roadmap : databases inline dans les pages (slash `/database`),
> galerie de **templates prédéfinis** (CRM, Project tracker, …) et **validation des
> propriétés** (required / unique / min / max) côté serveur + UI.
### Added — Inline databases (slash `/database`)
- Commande slash **`/database`** (groupe DATA) dans l'éditeur : ouvre un **sélecteur de
templates**, crée une database inline liée à la page et insère un bloc
`embed_type:'collection'` rendu par `FlowDeckDB.renderInto`.
- Le bouton « Database » de la barre *Get Started* ouvre désormais le même sélecteur
(au lieu de créer une database vide directement).
### Added — Templates de database prédéfinis
- **6 templates intégrés** seedés (migration v4) : Project tracker, CRM / Contacts,
Task list, Content calendar, Meeting notes, Reading list — avec icône, description et
schéma de propriétés.
- `app/services/db_templates.py` : `materialize_properties()` (schéma → `collection_properties`)
et `create_from_template()` (collection + propriétés + vue par défaut).
- `POST /db/api` et `POST /db/inline/api` acceptent `"template": "<nom>"` et matérialisent
les propriétés (titre exclu — c'est le titre de ligne). Colonne `icon` ajoutée à
`database_templates` (migration v4).
- `apply_db_template` (`/workspace/templates/database/{id}/apply`) matérialise les propriétés.
### Added — Validation des propriétés (required / unique / min / max)
- Nouvelle colonne `collection_properties.validation_json` (migration v4) ; le type/format
et les règles de validation sont portés par `validate_property_rule()` dans
`property_types.py` (required, unique, min, max, min_length, max_length).
- **Serveur** : `create_page_api` et `update_page_api` (`/db/{c}/pages/api`,
`/db/pages/{id}/api`) valident les valeurs avant écriture → 400 + message clair. La règle
`unique` exclut la page en cours d'édition. Les valeurs sont résolues par id **ou** par nom.
- **API propriétés** : `POST/PUT /db/{c}/properties/api` acceptent `validation` (persisté).
- **UI** (`_database_table_scripts.html`) : la modale « Add property » expose Required,
Unique, Min, Max ; en cas de 400 à l'édition d'une cellule, la cellule est marquée en rouge
et un toast affiche le message (valeur restaurée).
### Tests
- **271 tests** (+9 dans `tests/test_db_advanced.py`) : templates seedés, création de
collection/inline depuis template (propriétés matérialisées), apply template, validation
required/unique/min-max (create + update, exclusion de la ligne courante), persistance de
la config de validation, payload collection.
> Livre deux chantiers du roadmap v5 : la **migration au système de schéma versionné**
> (v5.2.0, priorité n°1 — débloque tout le reste) et la **Command Palette + recherche
+8 -6
View File
@@ -466,10 +466,12 @@ app/
- [ ] Tests des adapters forge (mock HTTP)
- [ ] Tests multi-user (permissions croisées)
### v5.3.0 — Database Avancée ⬜ (partiel)
- [ ] **Inline databases dans n'importe quelle page** *(PARTIEL : API `POST /db/inline/api` existe depuis v4.1.0 ; manque l'insertion via slash command `/database` + bloc rendu dans l'éditeur)*
- [ ] **Templates de database prédéfinis** (CRM, Project tracker…) — galerie au clic « New database » *(templates de pages existent, pas de galerie DB)*
- [ ] **Validation des propriétés** (required, unique, min/max) — côté serveur + messages UI
### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
### v5.4.0 — Expérience éditeur (nouveautés, parité Notion)
- [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes)
@@ -601,7 +603,7 @@ app/
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`)
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks)
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
---
@@ -622,4 +624,4 @@ Quality DB views, Agent IA Palette → Éditeur bloc Re
DB avancée, Wiki-links,
Calendrier, AI Templates & lock
*Dernière mise à jour: 2026-09-06 — v5.0.0 Command Palette + FTS5 et v5.2.0 Migrations versionnées (schema_version) + indexes livrés (267 tests) ; reste v5.3 → v6.0*
*Dernière mise à jour: 2026-09-06 — v5.0.0 (palette+FTS5) et v5.2.0 (migrations versionnées) livrés ; v5.3.0 Database Avancée livré (inline `/database`, 6 templates prédéfinis, validation propriétés, 280 tests) ; reste v5.4 → v6.0*
+1 -1
View File
@@ -1 +1 @@
5.0.0
5.1.0
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.0.0",
version="5.1.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+24 -1
View File
@@ -169,4 +169,27 @@ def _migration_fts5(conn: sqlite3.Connection) -> None:
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
WHERE deleted_at IS NULL
"""
)
)
@register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
# Seed built-in templates (idempotent: only missing names are inserted).
from app.services.db_templates import SEED_TEMPLATES
for tpl in SEED_TEMPLATES:
conn.execute(
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
VALUES (?, ?, ?, ?)""",
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
__import__("json").dumps(tpl.get("schema", []))),
)
+127 -16
View File
@@ -10,11 +10,73 @@ from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.auth.session import SessionManager
from app.services.property_types import validate_property_rule
from app.services.db_templates import materialize_properties
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
def _apply_template(conn, template_name: str) -> dict | None:
"""Resolve a database template by name (from the seeded/built-in set)."""
if not template_name:
return None
row = conn.execute(
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
(template_name,),
).fetchone()
if row:
return dict(row)
return None
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
"""Validate submitted property values against the collection's schema.
Raises ``HTTPException(400)`` with a user-friendly message on the first
failure (type, required, unique, min/max).
"""
props = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
).fetchall()
for prop in props:
ptype = prop["prop_type"]
if ptype == "title":
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
value = properties.get(str(pid))
if value is None:
value = properties.get(prop["name"])
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
existing_values = None
try:
import json as _json
vcfg = _json.loads(validation) if validation else {}
except Exception:
vcfg = {}
if vcfg.get("unique"):
rows = conn.execute(
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
existing_values = []
for r in rows:
if exclude_page_id is not None and r["id"] == exclude_page_id:
continue
try:
pv = _json.loads(r["property_values_json"] or "{}")
except Exception:
pv = {}
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
if not ok:
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
# ── API: List & Create (no path params) ──
@@ -46,7 +108,7 @@ async def list_collections_api(request: Request):
@router.post("/api")
async def create_collection_api(request: Request):
"""API: create a new collection."""
"""API: create a new collection, optionally from a database template."""
try:
body = await request.json()
except Exception:
@@ -60,10 +122,24 @@ async def create_collection_api(request: Request):
icon = body.get("icon", "📋")
gitea_owner = body.get("gitea_owner")
gitea_repo = body.get("gitea_repo")
schema_json = json.dumps(body.get("schema", []))
schema = body.get("schema", [])
is_locked = body.get("is_locked", False)
with get_conn() as conn:
# Apply a template if requested (provides schema + icon).
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
schema_json = json.dumps(schema)
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
@@ -72,6 +148,8 @@ async def create_collection_api(request: Request):
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
@@ -176,9 +254,18 @@ async def update_page_api(request: Request, page_id: int):
icon = body.get("icon", existing["icon"])
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
property_values = json.dumps(
body.get("properties", json.loads(existing["property_values_json"]))
)
if "properties" in body:
props = body["properties"]
else:
try:
props = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
props = {}
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
@@ -289,6 +376,7 @@ async def create_property_api(request: Request, collection_id: int):
number_format = body.get("number_format", "number")
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -304,9 +392,10 @@ async def create_property_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos, required, visible),
position, required, visible_in_views, validation_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json),
)
conn.commit()
except Exception:
@@ -335,12 +424,16 @@ async def update_property_api(request: Request, prop_id: int):
number_format = body.get("number_format", existing["number_format"])
required = int(body.get("required", existing["required"]))
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
if "validation" in body:
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
WHERE id=?""",
(name, options_json, number_format, required, visible, prop_id),
(name, options_json, number_format, required, visible, validation_json, prop_id),
)
conn.commit()
@@ -964,7 +1057,7 @@ async def toggle_inline(request: Request, collection_id: int):
@router.post("/inline/api")
async def create_inline_database(request: Request):
"""API: create an inline database within a parent page."""
"""API: create an inline database within a parent page (optionally from a template)."""
try:
body = await request.json()
except Exception:
@@ -978,16 +1071,30 @@ async def create_inline_database(request: Request):
icon = body.get("icon", "📋")
parent_page_id = body.get("parent_page_id")
workspace_id = body.get("workspace_id")
schema = body.get("schema", [])
with get_conn() as conn:
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id)
VALUES (?, ?, ?, '[]', 1, ?)""",
(name, description, icon, parent_page_id),
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
# Create default view
conn.execute(
"""INSERT INTO collection_views
@@ -1004,6 +1111,7 @@ async def create_inline_database(request: Request):
return {
"id": collection_id,
"name": name,
"icon": icon,
"is_inline": True,
"parent_page_id": parent_page_id,
"status": "created",
@@ -1763,7 +1871,7 @@ async def create_page_api(request: Request, collection_id: int):
raise HTTPException(status_code=400, detail="title is required")
icon = body.get("icon", "📄")
property_values = json.dumps(body.get("properties", {}))
property_values = body.get("properties", {})
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
@@ -1772,6 +1880,8 @@ async def create_page_api(request: Request, collection_id: int):
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
@@ -1781,7 +1891,8 @@ async def create_page_api(request: Request, collection_id: int):
"""INSERT INTO collection_pages
(collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number, property_values),
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
page_id = cur.lastrowid
+6 -11
View File
@@ -231,8 +231,9 @@ async def create_db_template(request: Request):
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
(body.get("name", "Template"), body.get("description", ""),
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@@ -240,22 +241,16 @@ async def create_db_template(request: Request):
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
from app.services.db_templates import create_from_template
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
collection_id = create_from_template(conn, name, dict(tmpl))
conn.commit()
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
return {"collection_id": collection_id, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
+201
View File
@@ -0,0 +1,201 @@
"""FlowDeck — Database templates (v5.3.0).
Defines the built-in (seeded) database templates, materializes a template's
schema into real ``collection_properties`` rows, and creates a collection from
a template. Templates are stored in ``database_templates`` (name, icon,
description, schema_json).
"""
from __future__ import annotations
import json
# ── Built-in templates ──
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
SEED_TEMPLATES: list[dict] = [
{
"name": "Project tracker",
"icon": "🚀",
"description": "Suivi de projets avec statut, priorité et échéances.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "Not started", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "orange"},
{"name": "Urgent", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "CRM / Contacts",
"icon": "👥",
"description": "Gestion des contacts et prospects.",
"schema": [
{"name": "Name", "type": "title"},
{"name": "Email", "type": "email"},
{"name": "Phone", "type": "phone"},
{"name": "Company", "type": "text"},
{"name": "Stage", "type": "status", "options": [
{"name": "Lead", "color": "gray"},
{"name": "Prospect", "color": "blue"},
{"name": "Customer", "color": "green"},
]},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "Task list",
"icon": "✅",
"description": "Liste de tâches simple avec assignation et échéance.",
"schema": [
{"name": "Task", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "To do", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
],
},
{
"name": "Content calendar",
"icon": "📅",
"description": "Planification de contenu et de publications.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Type", "type": "select", "options": [
{"name": "Article", "color": "blue"},
{"name": "Video", "color": "orange"},
{"name": "Social", "color": "green"},
{"name": "Newsletter", "color": "purple"},
]},
{"name": "Status", "type": "status", "options": [
{"name": "Draft", "color": "gray"},
{"name": "In review", "color": "yellow"},
{"name": "Published", "color": "green"},
]},
{"name": "Publish date", "type": "date"},
{"name": "Category", "type": "select"},
],
},
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants et décisions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
{"name": "Attendees", "type": "person"},
{"name": "Status", "type": "status", "options": [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Action items", "type": "multi_select"},
],
},
{
"name": "Reading list",
"icon": "📚",
"description": "Articles, livres et ressources à lire.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "URL", "type": "url"},
{"name": "Status", "type": "status", "options": [
{"name": "To read", "color": "gray"},
{"name": "Reading", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Notes", "type": "text"},
],
},
]
def materialize_properties(conn, collection_id: int, schema: list) -> None:
"""Insert ``collection_properties`` rows from a template ``schema``.
The ``title`` property is represented by ``collection_pages.title`` and is
not created as a column. Rows are inserted in schema order.
"""
position = 0
for prop in schema:
name = (prop.get("name") or "").strip()
if not name:
continue
prop_type = prop.get("type", "text")
if prop_type == "title":
continue
options = prop.get("options") or []
# idempotency guard — skip if a same-named property already exists
exists = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, name),
).fetchone()
if exists:
continue
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format, position)
VALUES (?, ?, ?, ?, 'number', ?)""",
(collection_id, name, prop_type, json.dumps(options), position),
)
position += 1
def create_from_template(
conn,
name: str,
template: dict,
*,
parent_page_id: int | None = None,
workspace_id: int | None = None,
) -> int:
"""Create a collection from a template (with properties + default view).
``template`` may be a DB row (sqlite Row) or a dict; it must expose
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
"""
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
description = template.get("description") if isinstance(template, dict) else template["description"]
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
try:
schema = json.loads(schema_json)
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description or "", icon or "📋", json.dumps(schema),
parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
return collection_id
+78
View File
@@ -149,6 +149,84 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list]
return True, ""
def parse_validation(validation) -> dict:
"""Normalize a property's ``validation_json`` into a config dict."""
if validation is None:
return {}
if isinstance(validation, dict):
cfg = dict(validation)
else:
try:
cfg = json.loads(validation) if validation else {}
except (json.JSONDecodeError, TypeError):
cfg = {}
return {
"required": bool(cfg.get("required", False)),
"unique": bool(cfg.get("unique", False)),
"min": cfg.get("min"),
"max": cfg.get("max"),
"min_length": cfg.get("min_length"),
"max_length": cfg.get("max_length"),
}
def validate_property_rule(
prop_type: str,
value: Any,
validation: Optional[dict] = None,
*,
existing_values: Optional[list] = None,
) -> tuple[bool, str]:
"""Validate a property value against type + validation rules.
Returns ``(ok, error_message)``. ``existing_values`` is used to enforce the
``unique`` rule (a list of the values already stored for that property).
"""
cfg = parse_validation(validation)
# Type-level validation first.
ok, msg = validate_property_value(prop_type, value)
if not ok:
return False, msg
# Empty / required
is_empty = value is None or value == "" or (isinstance(value, list) and len(value) == 0)
if is_empty:
if cfg.get("required"):
return False, "This property is required"
return True, ""
# Length bounds (string types)
if isinstance(value, str):
if cfg.get("min_length") is not None and len(value) < int(cfg["min_length"]):
return False, f"Must be at least {int(cfg['min_length'])} characters"
if cfg.get("max_length") is not None and len(value) > int(cfg["max_length"]):
return False, f"Must be at most {int(cfg['max_length'])} characters"
# Numeric bounds
if prop_type == "number" or (isinstance(value, (int, float)) and not isinstance(value, bool)):
try:
num = float(value)
except (ValueError, TypeError):
num = None
if num is not None:
if cfg.get("min") is not None and num < float(cfg["min"]):
return False, f"Must be greater than or equal to {cfg['min']}"
if cfg.get("max") is not None and num > float(cfg["max"]):
return False, f"Must be less than or equal to {cfg['max']}"
# Unique
if cfg.get("unique") and existing_values is not None:
norm = str(value).strip().lower()
for ev in existing_values:
if ev is None:
continue
if str(ev).strip().lower() == norm:
return False, "Value already exists (must be unique)"
return True, ""
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
+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(); }
+39
View File
@@ -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()">&times;</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>
+36 -12
View File
@@ -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='';}}
+155
View File
@@ -0,0 +1,155 @@
"""FlowDeck — v5.3.0 tests: inline databases, database templates, property validation."""
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
from app.main import app
from app.db import init_db
init_db()
yield TestClient(app)
os.unlink(db_path)
# ── Database templates ──
def test_seed_templates_available(client):
resp = client.get("/workspace/templates/database")
assert resp.status_code == 200
templates = resp.json()["templates"]
names = {t["name"] for t in templates}
assert "Project tracker" in names
assert "CRM / Contacts" in names
for t in templates:
assert t.get("icon")
def _props(client, cid):
return client.get(f"/db/{cid}/properties/api").json()["properties"]
def test_create_collection_from_template_materializes_properties(client):
resp = client.post("/db/api", json={"name": "My CRM", "template": "CRM / Contacts"})
assert resp.status_code == 200
cid = resp.json()["id"]
# Properties materialized (status + person/email etc.), title skipped
props = _props(client, cid)
names = {p["name"]: p for p in props}
assert "Email" in names
assert "Stage" in names
assert names["Stage"]["prop_type"] == "status"
assert "Title" not in names # title is the row title, not a column
def test_create_inline_database_from_template(client):
resp = client.post("/db/inline/api", json={"name": "Tasks", "template": "Task list", "parent_page_id": 1})
assert resp.status_code == 200
cid = resp.json()["id"]
assert resp.json()["is_inline"] is True
props = _props(client, cid)
names = {p["name"] for p in props}
assert {"Status", "Priority", "Due date"} <= names
def test_apply_db_template_endpoint(client):
resp = client.get("/workspace/templates/database").json()
tpl = next(t for t in resp["templates"] if t["name"] == "Project tracker")
r = client.post(f"/workspace/templates/database/{tpl['id']}/apply", json={"name": "Proj"})
assert r.status_code == 200
cid = r.json()["collection_id"]
props = _props(client, cid)
names = {p["name"] for p in props}
assert {"Status", "Priority", "Assignee"} <= names
# ── Property validation ──
def _add_collection(client, name="V"):
return client.post("/db/api", json={"name": name}).json()["id"]
def test_required_property_validation(client):
cid = _add_collection(client)
client.post(f"/db/{cid}/properties/api", json={"name": "Email", "prop_type": "email",
"validation": {"required": True}})
# Missing required → 400
r = client.post(f"/db/{cid}/pages/api", json={"title": "Row", "properties": {}})
assert r.status_code == 400
assert "required" in r.json()["detail"].lower()
# With value → ok
r = client.post(f"/db/{cid}/pages/api", json={"title": "Row", "properties": {"Email": "[email protected]"}})
assert r.status_code == 200
def test_unique_property_validation(client):
cid = _add_collection(client)
client.post(f"/db/{cid}/properties/api", json={"name": "Code", "prop_type": "text",
"validation": {"unique": True}})
client.post(f"/db/{cid}/pages/api", json={"title": "R1", "properties": {"Code": "abc"}})
# Duplicate → 400
r = client.post(f"/db/{cid}/pages/api", json={"title": "R2", "properties": {"Code": "abc"}})
assert r.status_code == 400
assert "unique" in r.json()["detail"].lower()
# Update to a duplicate also fails
pid = client.post(f"/db/{cid}/pages/api", json={"title": "R3", "properties": {"Code": "xyz"}}).json()["id"]
r = client.put(f"/db/pages/{pid}/api", json={"properties": {"Code": "abc"}})
assert r.status_code == 400
# Same row keeping its own value is allowed
r = client.put(f"/db/pages/{pid}/api", json={"properties": {"Code": "xyz"}})
assert r.status_code == 200
def test_min_max_validation(client):
cid = _add_collection(client)
client.post(f"/db/{cid}/properties/api", json={"name": "Score", "prop_type": "number",
"validation": {"min": 0, "max": 100}})
r = client.post(f"/db/{cid}/pages/api", json={"title": "R", "properties": {"Score": -5}})
assert r.status_code == 400
r = client.post(f"/db/{cid}/pages/api", json={"title": "R", "properties": {"Score": 150}})
assert r.status_code == 400
r = client.post(f"/db/{cid}/pages/api", json={"title": "R", "properties": {"Score": 50}})
assert r.status_code == 200
def test_property_validation_persisted_and_readable(client):
cid = _add_collection(client)
r = client.post(f"/db/{cid}/properties/api", json={"name": "P", "prop_type": "number",
"validation": {"min": 1, "max": 9}})
pid = r.json()["id"]
props = _props(client, cid)
p = next(x for x in props if x["id"] == pid)
assert json.loads(p["validation_json"]) == {"min": 1, "max": 9}
# ── Inline database block data (via /db collection payload) ──
def test_inline_db_collection_payload(client):
cid = _add_collection(client)
r = client.get(f"/db/{cid}/api")
assert r.status_code == 200
data = r.json()
assert data["collection"]["id"] == cid
assert "views" in data