diff --git a/app/routers/collections.py b/app/routers/collections.py index c02e2d4..b06cbc6 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -1052,6 +1052,16 @@ def _render_view(view_type: str, collection: dict, pages: list[dict], config: di return _render_list(view_type, collection, pages, config) elif view_type == "timeline": return _render_timeline(view_type, collection, pages, config) + elif view_type == "chart": + return _render_chart(view_type, collection, pages, config) + elif view_type == "form": + return _render_form(view_type, collection, pages, config) + elif view_type == "map": + return _render_map(view_type, collection, pages, config) + elif view_type == "feed": + return _render_feed(view_type, collection, pages, config) + elif view_type == "gantt": + return _render_gantt(view_type, collection, pages, config) else: return _render_table(view_type, collection, pages, config) @@ -1074,6 +1084,11 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}} πŸ–ΌοΈ Gallery πŸ“ List πŸ“ˆ Timeline +πŸ“Š Gantt +πŸ“‰ Chart +πŸ“‹ Form +πŸ—ΊοΈ Map +πŸ“° Feed {body} """ @@ -1278,6 +1293,258 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config """) +# ── v4.3.0: New view types ── + +def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + """Chart view β€” bar, line, pie, doughnut, scatter via Chart.js CDN.""" + chart_type = config.get("chart_type", "bar") + chart_property = config.get("chart_property", "") + + labels = [] + values = [] + for p in pages: + labels.append(p["title"][:30]) + props = json.loads(p.get("property_values_json", "{}")) + val = 0 + if chart_property: + v_raw = props.get(chart_property, 0) + try: + val = float(v_raw) if v_raw else 0 + except (ValueError, TypeError): + val = 0 + values.append(val if val else 1) + + labels_json = json.dumps(labels) + values_json = json.dumps(values) + + return _base_html(collection["name"], collection.get("icon", "πŸ“Š"), view_type, f""" + +
+ + +

{len(pages)} entries

+""") + + +def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + """Form view β€” generates an HTML form that creates new pages in the collection.""" + from app.db import get_conn + properties = [] + with get_conn() as conn: + props = conn.execute( + "SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position", + (collection["id"],), + ).fetchall() + for p in props: + prop_dict = dict(p) + prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]")) + properties.append(prop_dict) + + fields = "" + for prop in properties: + name = prop["name"] + ptype = prop["prop_type"] + if ptype in ("text", "email", "url", "phone", "number"): + fields += f"""
""" + elif ptype in ("select", "status"): + options = "".join(f"" for o in prop.get("options", [])) + fields += f"""
""" + elif ptype == "checkbox": + fields += f"""
""" + elif ptype == "date": + fields += f"""
""" + + return _base_html(collection["name"], collection.get("icon", "πŸ“"), view_type, f""" + +
+

New entry in {collection['name']}

+
+{fields} +
+ +
+ +
+ +""") + + +def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + """Map view β€” displays pages with location data on Leaflet map.""" + markers = [] + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + lat, lng = None, None + for k, v in props.items(): + if isinstance(v, str) and "," in v: + parts = v.split(",") + try: + lat, lng = float(parts[0].strip()), float(parts[1].strip()) + except ValueError: + continue + elif isinstance(v, dict): + lat = v.get("lat") + lng = v.get("lng") + if lat and lng: + markers.append({"title": p["title"], "lat": lat, "lng": lng}) + + markers_json = json.dumps(markers) + center_lat = markers[0]["lat"] if markers else 45.5 + center_lng = markers[0]["lng"] if markers else -73.5 + + return _base_html(collection["name"], collection.get("icon", "πŸ—ΊοΈ"), view_type, f""" + + +
+ + +

{len(markers)} location(s) mapped

+""") + + +def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + """Feed view β€” chronological feed of pages, newest first.""" + sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True) + items = "" + for p in sorted_pages: + created = p.get("created_at", "")[:10] if p.get("created_at") else "" + items += f"""
+
{created}
+
{p.get('icon','πŸ“„')} {p['title']}
+
""" + + return _base_html(collection["name"], collection.get("icon", "πŸ“°"), view_type, f""" + +{items} +

{len(sorted_pages)} entries

+""") + + +def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + """Gantt view β€” timeline with dependencies and group_by support.""" + group_by = config.get("group_by", "") + + gantt_data = [] + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + group = None + start_val = end_val = None + for k, v in props.items(): + if isinstance(v, str) and v.startswith("20"): + if "β†’" in v: + parts = v.split("β†’") + start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10] + elif "..." in v: + parts = v.split("...") + start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10] + else: + start_val = end_val = v[:10] + break + if group_by: + group = str(props.get(group_by, props.get("Status", "")))[:20] + if start_val: + gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""}) + + if not gantt_data: + return _base_html(collection["name"], collection.get("icon", "πŸ“Š"), view_type, "

No date data for Gantt chart.

") + + from datetime import date as dt_date_2 + all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data] + min_date = min(dt_date_2.fromisoformat(d) for d in all_dates) + max_date = max(dt_date_2.fromisoformat(d) for d in all_dates) + total_days = max((max_date - min_date).days, 1) + + groups = {} + for d in gantt_data: + groups.setdefault(d["group"], []).append(d) + if not groups or all(k == "" for k in groups): + groups = {"": gantt_data} + + rows = "" + for group_name, items in sorted(groups.items()): + if group_name: + rows += f"
{group_name} ({len(items)})
" + for item in items: + sd = dt_date_2.fromisoformat(item["start"]) + ed = dt_date_2.fromisoformat(item["end"]) + left = max((sd - min_date).days / total_days * 100, 0) + width = max((ed - sd).days / total_days * 100, 1) + rows += f"""
+{item['title'][:30]} +
+{item['start']} β†’ {item['end']} +
""" + + return _base_html(collection["name"], collection.get("icon", "πŸ“Š"), view_type, f""" + +
+{min_date} +{max_date} +
+{rows} +

{len(gantt_data)} items Β· {min_date} β†’ {max_date}

+""") + + def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: rows = "" for p in pages: diff --git a/tests/test_app.py b/tests/test_app.py index 463680e..1530cde 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2375,3 +2375,80 @@ def test_dashboard_create_default_layout(client): layout = json.loads(dash["layout_json"]) assert layout["columns"] == 1 assert layout["widgets"] == [] + + +# ── v4.3.0: Database Views (10 types) ── + + +def test_view_chart_renders(client): + """Chart view renders with Chart.js CDN.""" + resp = client.post("/db/api", json={"name": "Chart DB"}) + coll_id = resp.json()["id"] + client.post(f"/db/{coll_id}/pages/api", json={"title": "Item A", "properties": {"Count": "5"}}) + client.post(f"/db/{coll_id}/pages/api", json={"title": "Item B", "properties": {"Count": "8"}}) + resp = client.get(f"/db/{coll_id}/view/chart") + assert resp.status_code == 200 + assert "chart.js" in resp.text + + +def test_view_form_renders(client): + """Form view renders with input fields.""" + resp = client.post("/db/api", json={"name": "Form DB"}) + coll_id = resp.json()["id"] + client.post(f"/db/{coll_id}/properties/api", json={"name": "Email", "prop_type": "email"}) + resp = client.get(f"/db/{coll_id}/view/form") + assert resp.status_code == 200 + assert "" in resp.text \ No newline at end of file