Improving the Work Item rendering.

This commit is contained in:
Yaro Kasear 2025-10-02 09:55:53 -05:00
parent 244f0945bb
commit 16d5f2ab98
6 changed files with 115 additions and 20 deletions

View file

@ -5,4 +5,5 @@ from .utils import (
deep_diff, deep_diff,
diff_to_patch, diff_to_patch,
filter_to_columns, filter_to_columns,
to_jsonable,
) )

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql import operators from sqlalchemy.sql import operators
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
from crudkit.core import deep_diff, diff_to_patch, filter_to_columns, normalize_payload from crudkit.core import to_jsonable, deep_diff, diff_to_patch, filter_to_columns, normalize_payload
from crudkit.core.base import Version from crudkit.core.base import Version
from crudkit.core.spec import CRUDSpec from crudkit.core.spec import CRUDSpec
from crudkit.core.types import OrderSpec, SeekWindow from crudkit.core.types import OrderSpec, SeekWindow
@ -731,6 +731,7 @@ class CRUDService(Generic[T]):
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None): def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None):
session = self.session session = self.session
try:
snapshot = {} snapshot = {}
try: try:
snapshot = obj.as_dict() snapshot = obj.as_dict()
@ -741,9 +742,12 @@ class CRUDService(Generic[T]):
model_name=self.model.__name__, model_name=self.model.__name__,
object_id=obj.id, object_id=obj.id,
change_type=change_type, change_type=change_type,
data=snapshot, data=to_jsonable(snapshot),
actor=str(actor) if actor else None, actor=str(actor) if actor else None,
meta=metadata or None, meta=to_jsonable(metadata) if metadata else None,
) )
session.add(version) session.add(version)
session.commit() session.commit()
except Exception as e:
log.warning(f"Version logging failed for {self.model.__name__} id={getattr(obj, "id", "?")}: {str(e)}")
session.rollback()

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, Optional, Callable from typing import Any, Dict, Optional, Callable
from sqlalchemy import inspect from sqlalchemy import inspect
@ -8,6 +10,32 @@ ISO_DT_FORMATS = ("%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M",
"%Y-%m-%d") "%Y-%m-%d")
def to_jsonable(obj: Any):
"""Recursively convert values into JSON-serializable forms."""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, Enum):
return obj.value
if isinstance(obj, dict):
return {str(k): to_jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [to_jsonable(v) for v in obj]
# fallback: strin-ify weird objects (UUID, ORM instances, etc.)
try:
return str(obj)
except Exception:
return None
def filter_to_columns(data: dict, model_cls): def filter_to_columns(data: dict, model_cls):
cols = {c.key for c in inspect(model_cls).mapper.columns} cols = {c.key for c in inspect(model_cls).mapper.columns}
return {k: v for k, v in data.items() if k in cols} return {k: v for k, v in data.items() if k in cols}

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from flask import Flask from flask import Flask
from jinja_markdown import MarkdownExtension
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.pool import Pool from sqlalchemy.pool import Pool
@ -18,6 +19,7 @@ from .routes.search import init_search_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask: def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.jinja_env.add_extension(MarkdownExtension)
init_pretty(app) init_pretty(app)

View file

@ -1,15 +1,74 @@
{% set items = (field.template_ctx.instance.updates or []) %} {% set items = (field.template_ctx.instance.updates or []) %}
<ul class="list-group mt-3"> <ul class="list-group mt-3">
{% for n in items %} {% for n in items %}
<li class="list-group-item"> <li class="list-group-item">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3" style="white-space: pre-wrap;">{{ n.content }}</div> <div class="me-3" id="editContainer{{ n.id }}"></div>
<small class="text-muted">
<div class="d-flex flex-column align-items-end">
<div id="editView{{ n.id }}">
<small class="text-muted text-nowrap">
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }} {{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
</small> </small>
</div> </div>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="editSwitch{{ n.id }}" switch onchange="changeMode({{ n.id }})">
<label for="editSwitch" class="form-check-label">Edit</label>
</div>
</div>
</div>
</li> </li>
{% else %} {% else %}
<li class="list-group-item text-muted">No updates yet.</li> <li class="list-group-item text-muted">No updates yet.</li>
{% endfor %} {% endfor %}
</ul> </ul>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script>
const contents = {};
{% for n in items %}
contents[{{ n.id }}] = {{ n.content | tojson }};
{% endfor %}
document.addEventListener('DOMContentLoaded', () => {
for (const [id, md] of Object.entries(contents)) {
renderView(+id, md);
}
});
function renderView(id, md) {
const container = document.getElementById(`editContainer${id}`);
const html = marked.parse(md ?? "");
container.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target', 'rel'],
});
for (const a of container.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow')
}
}
function changeMode(id) {
const container = document.getElementById(`editContainer${id}`);
const toggle = document.getElementById(`editSwitch${id}`);
if (toggle.checked) {
// Switch to editor mode
const current = contents[id] ?? "";
container.dataset.prev = container.innerHTML;
container.innerHTML = `
<textarea class="form-control" id="editor${id}" rows="6">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2">
<button class="btn btn=primary btn-sm" onclick="saveEdit(${id})">Save</button>
</div>
`;
} else {
// Switch to viewer mode
renderView(id, contents[id]);
}
}
</script>

View file

@ -9,6 +9,7 @@ dependencies = [
"flask", "flask",
"flask_sqlalchemy", "flask_sqlalchemy",
"html5lib", "html5lib",
"jinja_markdown",
"pandas", "pandas",
"pyodbc", "pyodbc",
"python-dotenv", "python-dotenv",