Added Rules preset (on top of format and style presets)
This commit is contained in:
395
examples/formatting_manager_mockup.html
Normal file
395
examples/formatting_manager_mockup.html
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DataGridFormattingManager — Visual Mockup</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
|
||||||
|
/* ---- Manager layout ---- */
|
||||||
|
.manager-root {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 520px;
|
||||||
|
border: 1px solid oklch(var(--b3));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Menu bar (Menu control pattern) ---- */
|
||||||
|
.mf-menu {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button — mirrors mk.icon() output */
|
||||||
|
.mf-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: oklch(var(--bc) / 0.7);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mf-icon-btn:hover { background: oklch(var(--b3)); color: oklch(var(--bc)); }
|
||||||
|
.mf-icon-btn.danger:hover { background: color-mix(in oklab, oklch(var(--er)) 15%, transparent); color: oklch(var(--er)); }
|
||||||
|
|
||||||
|
/* Tooltip — mirrors mk.icon(tooltip=...) */
|
||||||
|
.mf-icon-btn::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: oklch(var(--n));
|
||||||
|
color: oklch(var(--nc));
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.mf-icon-btn:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
.menu-separator { width: 1px; height: 1.2rem; background: oklch(var(--b3)); margin: 0 0.15rem; }
|
||||||
|
|
||||||
|
/* ---- Preset list ---- */
|
||||||
|
.preset-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid oklch(var(--b3));
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid oklch(var(--b2));
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.preset-item:hover { background: oklch(var(--b2)); }
|
||||||
|
.preset-item.active {
|
||||||
|
background: color-mix(in oklab, oklch(var(--p)) 10%, transparent);
|
||||||
|
border-left: 3px solid oklch(var(--p));
|
||||||
|
padding-left: calc(0.75rem - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-name { font-size: 0.875rem; font-weight: 600; }
|
||||||
|
.preset-desc { font-size: 0.7rem; color: oklch(var(--bc) / 0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.preset-badges { display: flex; gap: 0.25rem; margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
/* ---- Editor panel ---- */
|
||||||
|
.editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta {
|
||||||
|
padding: 0.6rem 1rem 0.5rem;
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsl-area {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsl-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: oklch(var(--bc) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsl-editor {
|
||||||
|
flex: 1;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid oklch(var(--b3));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
color: oklch(var(--bc));
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.dsl-editor:focus { border-color: oklch(var(--p)); }
|
||||||
|
|
||||||
|
/* ---- Code block (Python def) ---- */
|
||||||
|
.token-keyword { color: oklch(var(--p)); font-weight: 700; }
|
||||||
|
.token-builtin { color: oklch(var(--s)); font-weight: 600; }
|
||||||
|
.token-string { color: oklch(var(--su)); }
|
||||||
|
.token-number { color: oklch(var(--a)); }
|
||||||
|
.token-comment { color: oklch(var(--bc) / 0.4); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 p-6">
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="text-xl font-bold">DataGridFormattingManager</h1>
|
||||||
|
<p class="text-sm text-base-content/50 mt-0.5">
|
||||||
|
Named rule presets combining formatters, styles and conditions.
|
||||||
|
Reference them with <code class="bg-base-300 px-1 rounded text-xs">format("preset_name")</code>
|
||||||
|
or <code class="bg-base-300 px-1 rounded text-xs">style("preset_name")</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manager control -->
|
||||||
|
<div class="manager-root shadow-md">
|
||||||
|
|
||||||
|
<!-- Menu bar — Menu(conf=MenuConf(fixed_items=["New","Save","Rename","Delete"]), save_state=False) -->
|
||||||
|
<div class="mf-menu">
|
||||||
|
<!-- fixed_items rendered as mk.icon(command, tooltip=command.description) -->
|
||||||
|
<button class="mf-icon-btn" data-tip="New preset" onclick="newPreset()">
|
||||||
|
<!-- Fluent: Add -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="mf-icon-btn" data-tip="Save preset" onclick="savePreset()">
|
||||||
|
<!-- Fluent: Save -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="mf-icon-btn" data-tip="Rename preset" onclick="renamePreset()">
|
||||||
|
<!-- Fluent: Rename -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<button class="mf-icon-btn danger" data-tip="Delete preset" onclick="deletePreset()">
|
||||||
|
<!-- Fluent: Delete -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Left: preset list -->
|
||||||
|
<div class="preset-list" id="preset-list"></div>
|
||||||
|
|
||||||
|
<!-- Right: editor -->
|
||||||
|
<div class="editor-panel">
|
||||||
|
<div class="editor-meta">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-semibold" id="editor-title">—</div>
|
||||||
|
<div class="text-xs text-base-content/45 mt-0.5" id="editor-desc">Select a preset to edit</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1" id="editor-badges"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dsl-area">
|
||||||
|
<div class="dsl-label">Rules — DSL</div>
|
||||||
|
<textarea class="dsl-editor" id="dsl-editor" spellcheck="false"
|
||||||
|
placeholder="No preset selected."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEFAULT_RULE_PRESETS example -->
|
||||||
|
<div class="mt-6 rounded-xl border border-base-300 bg-base-100 overflow-hidden shadow">
|
||||||
|
<div class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs font-bold uppercase tracking-wide text-base-content/50">
|
||||||
|
DEFAULT_RULE_PRESETS — core/formatting/presets.py
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 text-xs font-mono overflow-x-auto leading-relaxed text-base-content"><span class="token-comment"># name + description + list of complete FormatRule descriptors.
|
||||||
|
# Appears in format() suggestions if at least one rule has a formatter.
|
||||||
|
# Appears in style() suggestions if at least one rule has a style.</span>
|
||||||
|
|
||||||
|
DEFAULT_RULE_PRESETS = {
|
||||||
|
|
||||||
|
<span class="token-string">"accounting"</span>: {
|
||||||
|
<span class="token-string">"description"</span>: <span class="token-string">"Negatives in parentheses (red), positives plain"</span>,
|
||||||
|
<span class="token-string">"rules"</span>: [
|
||||||
|
{
|
||||||
|
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||||
|
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
|
||||||
|
<span class="token-string">"prefix"</span>: <span class="token-string">"("</span>, <span class="token-string">"suffix"</span>: <span class="token-string">")"</span>,
|
||||||
|
<span class="token-string">"absolute"</span>: <span class="token-keyword">True</span>, <span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
|
||||||
|
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||||
|
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">0</span>,
|
||||||
|
<span class="token-string">"thousands_sep"</span>: <span class="token-string">" "</span>},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
<span class="token-string">"traffic_light"</span>: {
|
||||||
|
<span class="token-string">"description"</span>: <span class="token-string">"Red / yellow / green style based on sign"</span>,
|
||||||
|
<span class="token-string">"rules"</span>: [
|
||||||
|
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>}},
|
||||||
|
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"=="</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>}},
|
||||||
|
{<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>}, <span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"success"</span>}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
<span class="token-string">"budget_variance"</span>: {
|
||||||
|
<span class="token-string">"description"</span>: <span class="token-string">"% variance: negative=error, over 10%=warning, else plain"</span>,
|
||||||
|
<span class="token-string">"rules"</span>: [
|
||||||
|
{
|
||||||
|
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">"<"</span>, <span class="token-string">"value"</span>: <span class="token-number">0</span>},
|
||||||
|
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||||
|
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"error"</span>},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
<span class="token-string">"condition"</span>: {<span class="token-string">"operator"</span>: <span class="token-string">">"</span>, <span class="token-string">"value"</span>: <span class="token-number">0.1</span>},
|
||||||
|
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||||
|
<span class="token-string">"style"</span>: {<span class="token-string">"preset"</span>: <span class="token-string">"warning"</span>},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
<span class="token-string">"formatter"</span>: {<span class="token-string">"type"</span>: <span class="token-string">"number"</span>, <span class="token-string">"precision"</span>: <span class="token-number">1</span>, <span class="token-string">"suffix"</span>: <span class="token-string">"%"</span>, <span class="token-string">"multiplier"</span>: <span class="token-number">100</span>},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const presets = [
|
||||||
|
{
|
||||||
|
id: "accounting", name: "accounting",
|
||||||
|
description: "Negatives in parentheses (red), positives plain",
|
||||||
|
hasFormatter: true, hasStyle: true,
|
||||||
|
dsl:
|
||||||
|
`format.number(precision=0, prefix="(", suffix=")", absolute=True, thousands_sep=" ") style("error") if value < 0
|
||||||
|
format.number(precision=0, thousands_sep=" ") if value > 0`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "traffic_light", name: "traffic_light",
|
||||||
|
description: "Red / yellow / green style based on sign",
|
||||||
|
hasFormatter: false, hasStyle: true,
|
||||||
|
dsl:
|
||||||
|
`style("error") if value < 0
|
||||||
|
style("warning") if value == 0
|
||||||
|
style("success") if value > 0`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "budget_variance", name: "budget_variance",
|
||||||
|
description: "% variance: negative=error, over 10%=warning, else plain",
|
||||||
|
hasFormatter: true, hasStyle: true,
|
||||||
|
dsl:
|
||||||
|
`format.number(precision=1, suffix="%", multiplier=100) style("error") if value < 0
|
||||||
|
format.number(precision=1, suffix="%", multiplier=100) style("warning") if value > 0.1
|
||||||
|
format.number(precision=1, suffix="%", multiplier=100)`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let activeId = null;
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const list = document.getElementById("preset-list");
|
||||||
|
list.innerHTML = "";
|
||||||
|
presets.forEach(p => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "preset-item" + (p.id === activeId ? " active" : "");
|
||||||
|
item.onclick = () => selectPreset(p.id);
|
||||||
|
const badges = [];
|
||||||
|
if (p.hasFormatter) badges.push(`<span class="badge badge-xs badge-secondary">Format</span>`);
|
||||||
|
if (p.hasStyle) badges.push(`<span class="badge badge-xs badge-primary">Style</span>`);
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="preset-name">${p.name}</div>
|
||||||
|
<div class="preset-desc">${p.description}</div>
|
||||||
|
<div class="preset-badges">${badges.join("")}</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(id) {
|
||||||
|
activeId = id;
|
||||||
|
const p = presets.find(x => x.id === id);
|
||||||
|
document.getElementById("editor-title").textContent = p.name;
|
||||||
|
document.getElementById("editor-desc").textContent = p.description;
|
||||||
|
document.getElementById("dsl-editor").value = p.dsl;
|
||||||
|
const badges = [];
|
||||||
|
if (p.hasFormatter) badges.push(`<span class="badge badge-secondary">format()</span>`);
|
||||||
|
if (p.hasStyle) badges.push(`<span class="badge badge-primary">style()</span>`);
|
||||||
|
document.getElementById("editor-badges").innerHTML = badges.join("");
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreset() {
|
||||||
|
if (!activeId) return;
|
||||||
|
const p = presets.find(x => x.id === activeId);
|
||||||
|
p.dsl = document.getElementById("dsl-editor").value;
|
||||||
|
p.hasFormatter = p.dsl.includes("format");
|
||||||
|
p.hasStyle = p.dsl.includes("style");
|
||||||
|
renderList();
|
||||||
|
showToast("Preset saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPreset() {
|
||||||
|
const name = prompt("Preset name:");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const id = name.trim().toLowerCase().replace(/\s+/g, "_");
|
||||||
|
if (presets.find(x => x.id === id)) { alert("Name already exists."); return; }
|
||||||
|
presets.push({ id, name: id, description: "New rule preset", hasFormatter: false, hasStyle: false, dsl: "" });
|
||||||
|
renderList();
|
||||||
|
selectPreset(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renamePreset() {
|
||||||
|
if (!activeId) return;
|
||||||
|
const p = presets.find(x => x.id === activeId);
|
||||||
|
const name = prompt("New name:", p.name);
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
p.name = name.trim();
|
||||||
|
p.id = p.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
activeId = p.id;
|
||||||
|
renderList();
|
||||||
|
document.getElementById("editor-title").textContent = p.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePreset() {
|
||||||
|
if (!activeId) return;
|
||||||
|
if (!confirm(`Delete "${activeId}"?`)) return;
|
||||||
|
presets.splice(presets.findIndex(x => x.id === activeId), 1);
|
||||||
|
activeId = null;
|
||||||
|
document.getElementById("editor-title").textContent = "—";
|
||||||
|
document.getElementById("editor-desc").textContent = "Select a preset to edit";
|
||||||
|
document.getElementById("dsl-editor").value = "";
|
||||||
|
document.getElementById("editor-badges").innerHTML = "";
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.className = "toast toast-end toast-bottom z-50";
|
||||||
|
t.innerHTML = `<div class="alert alert-success text-sm py-2 px-4">${msg}</div>`;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
selectPreset("accounting");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,164 +6,195 @@ from typing import Any
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Condition:
|
class Condition:
|
||||||
"""
|
"""
|
||||||
Represents a condition for conditional formatting.
|
Represents a condition for conditional formatting.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
|
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
|
||||||
"contains", "startswith", "endswith", "in", "between",
|
"contains", "startswith", "endswith", "in", "between",
|
||||||
"isempty", "isnotempty")
|
"isempty", "isnotempty")
|
||||||
value: Value to compare against (literal, list, or {"col": "..."} for reference)
|
value: Value to compare against (literal, list, or {"col": "..."} for reference)
|
||||||
negate: If True, inverts the condition result
|
negate: If True, inverts the condition result
|
||||||
case_sensitive: If True, string comparisons are case-sensitive (default False)
|
case_sensitive: If True, string comparisons are case-sensitive (default False)
|
||||||
col: Column ID for row-level conditions (evaluate this column instead of current cell)
|
col: Column ID for row-level conditions (evaluate this column instead of current cell)
|
||||||
row: Row index for column-level conditions (evaluate this row instead of current cell)
|
row: Row index for column-level conditions (evaluate this row instead of current cell)
|
||||||
"""
|
"""
|
||||||
operator: str
|
operator: str
|
||||||
value: Any = None
|
value: Any = None
|
||||||
negate: bool = False
|
negate: bool = False
|
||||||
case_sensitive: bool = False
|
case_sensitive: bool = False
|
||||||
col: str = None
|
col: str = None
|
||||||
row: int = None
|
row: int = None
|
||||||
|
|
||||||
|
|
||||||
# === Style ===
|
# === Style ===
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Style:
|
class Style:
|
||||||
"""
|
"""
|
||||||
Represents style properties for cell formatting.
|
Represents style properties for cell formatting.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
preset: Name of a style preset ("primary", "success", "error", etc.)
|
preset: Name of a style preset ("primary", "success", "error", etc.)
|
||||||
background_color: Background color (hex, CSS name, or CSS variable)
|
background_color: Background color (hex, CSS name, or CSS variable)
|
||||||
color: Text color
|
color: Text color
|
||||||
font_weight: "normal" or "bold"
|
font_weight: "normal" or "bold"
|
||||||
font_style: "normal" or "italic"
|
font_style: "normal" or "italic"
|
||||||
font_size: Font size ("12px", "0.9em")
|
font_size: Font size ("12px", "0.9em")
|
||||||
text_decoration: "none", "underline", or "line-through"
|
text_decoration: "none", "underline", or "line-through"
|
||||||
"""
|
"""
|
||||||
preset: str = None
|
preset: str = None
|
||||||
background_color: str = None
|
background_color: str = None
|
||||||
color: str = None
|
color: str = None
|
||||||
font_weight: str = None
|
font_weight: str = None
|
||||||
font_style: str = None
|
font_style: str = None
|
||||||
font_size: str = None
|
font_size: str = None
|
||||||
text_decoration: str = None
|
text_decoration: str = None
|
||||||
|
|
||||||
|
|
||||||
# === Formatters ===
|
# === Formatters ===
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Formatter:
|
class Formatter:
|
||||||
"""Base class for all formatters."""
|
"""Base class for all formatters."""
|
||||||
preset: str = None
|
preset: str = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NumberFormatter(Formatter):
|
class NumberFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Formatter for numbers, currencies, and percentages.
|
Formatter for numbers, currencies, and percentages.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
prefix: Text before value (e.g., "$")
|
prefix: Text before value (e.g., "$")
|
||||||
suffix: Text after value (e.g., " EUR")
|
suffix: Text after value (e.g., " EUR")
|
||||||
thousands_sep: Thousands separator (e.g., ",", " ")
|
thousands_sep: Thousands separator (e.g., ",", " ")
|
||||||
decimal_sep: Decimal separator (e.g., ".", ",")
|
decimal_sep: Decimal separator (e.g., ".", ",")
|
||||||
precision: Number of decimal places
|
precision: Number of decimal places
|
||||||
multiplier: Multiply value before display (e.g., 100 for percentage)
|
multiplier: Multiply value before display (e.g., 100 for percentage)
|
||||||
"""
|
"""
|
||||||
prefix: str = ""
|
prefix: str = ""
|
||||||
suffix: str = ""
|
suffix: str = ""
|
||||||
thousands_sep: str = ""
|
thousands_sep: str = ""
|
||||||
decimal_sep: str = "."
|
decimal_sep: str = "."
|
||||||
precision: int = 0
|
precision: int = 0
|
||||||
multiplier: float = 1.0
|
multiplier: float = 1.0
|
||||||
absolute: bool = False
|
absolute: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DateFormatter(Formatter):
|
class DateFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Formatter for dates and datetimes.
|
Formatter for dates and datetimes.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
format: strftime format pattern (default: "%Y-%m-%d")
|
format: strftime format pattern (default: "%Y-%m-%d")
|
||||||
"""
|
"""
|
||||||
format: str = "%Y-%m-%d"
|
format: str = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BooleanFormatter(Formatter):
|
class BooleanFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Formatter for boolean values.
|
Formatter for boolean values.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
true_value: Display string for True
|
true_value: Display string for True
|
||||||
false_value: Display string for False
|
false_value: Display string for False
|
||||||
null_value: Display string for None/null
|
null_value: Display string for None/null
|
||||||
"""
|
"""
|
||||||
true_value: str = "true"
|
true_value: str = "true"
|
||||||
false_value: str = "false"
|
false_value: str = "false"
|
||||||
null_value: str = ""
|
null_value: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TextFormatter(Formatter):
|
class TextFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Formatter for text transformations.
|
Formatter for text transformations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
transform: Text transformation ("uppercase", "lowercase", "capitalize")
|
||||||
|
max_length: Maximum length before truncation
|
||||||
|
ellipsis: Suffix when truncated (default: "...")
|
||||||
|
"""
|
||||||
|
transform: str = None
|
||||||
|
max_length: int = None
|
||||||
|
ellipsis: str = "..."
|
||||||
|
|
||||||
Attributes:
|
|
||||||
transform: Text transformation ("uppercase", "lowercase", "capitalize")
|
|
||||||
max_length: Maximum length before truncation
|
|
||||||
ellipsis: Suffix when truncated (default: "...")
|
|
||||||
"""
|
|
||||||
transform: str = None
|
|
||||||
max_length: int = None
|
|
||||||
ellipsis: str = "..."
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConstantFormatter(Formatter):
|
class ConstantFormatter(Formatter):
|
||||||
value: str = None
|
value: str = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EnumFormatter(Formatter):
|
class EnumFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Formatter for mapping values to display labels.
|
Formatter for mapping values to display labels.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
source: Data source dict with "type" and "value" keys
|
source: Data source dict with "type" and "value" keys
|
||||||
- {"type": "mapping", "value": {"key": "label", ...}}
|
- {"type": "mapping", "value": {"key": "label", ...}}
|
||||||
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
|
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
|
||||||
default: Label for unknown values
|
default: Label for unknown values
|
||||||
allow_empty: Show empty option in Select dropdowns
|
allow_empty: Show empty option in Select dropdowns
|
||||||
empty_label: Label for empty option
|
empty_label: Label for empty option
|
||||||
order_by: Sort order ("source", "display", "value")
|
order_by: Sort order ("source", "display", "value")
|
||||||
"""
|
"""
|
||||||
source: dict = field(default_factory=dict)
|
source: dict = field(default_factory=dict)
|
||||||
default: str = ""
|
default: str = ""
|
||||||
allow_empty: bool = True
|
allow_empty: bool = True
|
||||||
empty_label: str = "-- Select --"
|
empty_label: str = "-- Select --"
|
||||||
order_by: str = "source"
|
order_by: str = "source"
|
||||||
|
|
||||||
|
|
||||||
# === Format Rule ===
|
# === Format Rule ===
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FormatRule:
|
class FormatRule:
|
||||||
"""
|
"""
|
||||||
A formatting rule combining condition, style, and formatter.
|
A formatting rule combining condition, style, and formatter.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- style and formatter can appear alone (unconditional formatting)
|
- style and formatter can appear alone (unconditional formatting)
|
||||||
- condition cannot appear alone - must be paired with style and/or formatter
|
- condition cannot appear alone - must be paired with style and/or formatter
|
||||||
- If condition is present, style/formatter is applied only if condition is met
|
- If condition is present, style/formatter is applied only if condition is met
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
condition: Optional condition for conditional formatting
|
condition: Optional condition for conditional formatting
|
||||||
style: Optional style to apply
|
style: Optional style to apply
|
||||||
formatter: Optional formatter to apply
|
formatter: Optional formatter to apply
|
||||||
"""
|
"""
|
||||||
condition: Condition = None
|
condition: Condition = None
|
||||||
style: Style = None
|
style: Style = None
|
||||||
formatter: Formatter = None
|
formatter: Formatter = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Rule Preset ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RulePreset:
|
||||||
|
"""
|
||||||
|
A named, reusable list of FormatRules.
|
||||||
|
|
||||||
|
Referenced in DSL as format("name") or style("name").
|
||||||
|
Appears in format() suggestions if at least one rule has a formatter.
|
||||||
|
Appears in style() suggestions if at least one rule has a style.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Unique identifier used in DSL (e.g. "accounting")
|
||||||
|
description: Human-readable description shown in the UI
|
||||||
|
rules: Ordered list of FormatRule to apply
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
rules: list[FormatRule] = field(default_factory=list)
|
||||||
|
|
||||||
|
def has_formatter(self) -> bool:
|
||||||
|
"""Returns True if at least one rule defines a formatter."""
|
||||||
|
return any(r.formatter is not None for r in self.rules)
|
||||||
|
|
||||||
|
def has_style(self) -> bool:
|
||||||
|
"""Returns True if at least one rule defines a style."""
|
||||||
|
return any(r.style is not None for r in self.rules)
|
||||||
|
|||||||
@@ -323,17 +323,23 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
|||||||
"""Get style preset suggestions (without quotes)."""
|
"""Get style preset suggestions (without quotes)."""
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
|
||||||
# Add provider presets if available
|
# Add rule presets that have at least one style rule
|
||||||
|
try:
|
||||||
|
for rule_preset in self.provider.list_rule_presets_for_style():
|
||||||
|
suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add provider custom style presets
|
||||||
try:
|
try:
|
||||||
custom_presets = self.provider.list_style_presets()
|
custom_presets = self.provider.list_style_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
# Check if it's already in default presets
|
|
||||||
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
if not any(s.label == preset for s in presets.STYLE_PRESETS):
|
||||||
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add default presets (just the name, no quotes - we're inside quotes)
|
# Add default style presets (no quotes — we're inside quotes)
|
||||||
for preset in presets.STYLE_PRESETS:
|
for preset in presets.STYLE_PRESETS:
|
||||||
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
||||||
|
|
||||||
@@ -343,7 +349,14 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
|||||||
"""Get style preset suggestions with quotes."""
|
"""Get style preset suggestions with quotes."""
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
|
||||||
# Add provider presets if available
|
# Add rule presets that have at least one style rule
|
||||||
|
try:
|
||||||
|
for rule_preset in self.provider.list_rule_presets_for_style():
|
||||||
|
suggestions.append(Suggestion(f'"{rule_preset.name}"', rule_preset.description, "rule_preset"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add provider custom style presets
|
||||||
try:
|
try:
|
||||||
custom_presets = self.provider.list_style_presets()
|
custom_presets = self.provider.list_style_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
@@ -352,7 +365,7 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add default presets with quotes
|
# Add default style presets with quotes
|
||||||
for preset in presets.STYLE_PRESETS:
|
for preset in presets.STYLE_PRESETS:
|
||||||
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
|
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
|
||||||
|
|
||||||
@@ -362,7 +375,14 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
|||||||
"""Get format preset suggestions (without quotes)."""
|
"""Get format preset suggestions (without quotes)."""
|
||||||
suggestions = []
|
suggestions = []
|
||||||
|
|
||||||
# Add provider presets if available
|
# Add rule presets that have at least one formatter rule
|
||||||
|
try:
|
||||||
|
for rule_preset in self.provider.list_rule_presets_for_format():
|
||||||
|
suggestions.append(Suggestion(rule_preset.name, rule_preset.description, "rule_preset"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add provider custom formatter presets
|
||||||
try:
|
try:
|
||||||
custom_presets = self.provider.list_format_presets()
|
custom_presets = self.provider.list_format_presets()
|
||||||
for preset in custom_presets:
|
for preset in custom_presets:
|
||||||
@@ -371,7 +391,7 @@ class FormattingCompletionEngine(BaseCompletionEngine):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add default presets
|
# Add default formatter presets
|
||||||
for preset in presets.FORMAT_PRESETS:
|
for preset in presets.FORMAT_PRESETS:
|
||||||
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from myfasthtml.core.data.DataServicesManager import DataServicesManager
|
from myfasthtml.core.data.DataServicesManager import DataServicesManager
|
||||||
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
|
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
|
||||||
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS
|
from myfasthtml.core.formatting.dataclasses import RulePreset
|
||||||
|
from myfasthtml.core.formatting.presets import (
|
||||||
|
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
|
||||||
|
)
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,6 +41,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
|
|||||||
super().__init__(parent, session, _id)
|
super().__init__(parent, session, _id)
|
||||||
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
||||||
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
||||||
|
self.rule_presets: dict[str, RulePreset] = DEFAULT_RULE_PRESETS.copy()
|
||||||
self.all_tables_formats: list = []
|
self.all_tables_formats: list = []
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -132,6 +136,14 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
|
|||||||
"""Return the names of all registered formatter presets."""
|
"""Return the names of all registered formatter presets."""
|
||||||
return list(self.formatter_presets.keys())
|
return list(self.formatter_presets.keys())
|
||||||
|
|
||||||
|
def list_rule_presets_for_format(self) -> list[RulePreset]:
|
||||||
|
"""Return rule presets that have at least one rule with a formatter."""
|
||||||
|
return [p for p in self.rule_presets.values() if p.has_formatter()]
|
||||||
|
|
||||||
|
def list_rule_presets_for_style(self) -> list[RulePreset]:
|
||||||
|
"""Return rule presets that have at least one rule with a style."""
|
||||||
|
return [p for p in self.rule_presets.values() if p.has_style()]
|
||||||
|
|
||||||
def get_style_presets(self) -> dict:
|
def get_style_presets(self) -> dict:
|
||||||
"""Return the full style presets dict."""
|
"""Return the full style presets dict."""
|
||||||
return self.style_presets
|
return self.style_presets
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any, Callable
|
|||||||
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
||||||
from myfasthtml.core.formatting.dataclasses import FormatRule
|
from myfasthtml.core.formatting.dataclasses import FormatRule
|
||||||
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
|
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
|
||||||
|
from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS
|
||||||
from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer
|
from myfasthtml.core.formatting.style_resolver import StyleResolver, StyleContainer
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ class FormattingEngine:
|
|||||||
self,
|
self,
|
||||||
style_presets: dict = None,
|
style_presets: dict = None,
|
||||||
formatter_presets: dict = None,
|
formatter_presets: dict = None,
|
||||||
|
rule_presets: dict = None,
|
||||||
lookup_resolver: Callable[[str, str, str], dict] = None
|
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -37,11 +39,13 @@ class FormattingEngine:
|
|||||||
Args:
|
Args:
|
||||||
style_presets: Custom style presets. If None, uses defaults.
|
style_presets: Custom style presets. If None, uses defaults.
|
||||||
formatter_presets: Custom formatter presets. If None, uses defaults.
|
formatter_presets: Custom formatter presets. If None, uses defaults.
|
||||||
|
rule_presets: Named rule presets (list of FormatRule dicts). If None, uses defaults.
|
||||||
lookup_resolver: Function for resolving enum datagrid sources.
|
lookup_resolver: Function for resolving enum datagrid sources.
|
||||||
"""
|
"""
|
||||||
self._condition_evaluator = ConditionEvaluator()
|
self._condition_evaluator = ConditionEvaluator()
|
||||||
self._style_resolver = StyleResolver(style_presets)
|
self._style_resolver = StyleResolver(style_presets)
|
||||||
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
|
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
|
||||||
|
self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
|
||||||
|
|
||||||
def apply_format(
|
def apply_format(
|
||||||
self,
|
self,
|
||||||
@@ -65,6 +69,9 @@ class FormattingEngine:
|
|||||||
if not rules:
|
if not rules:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
# Expand rule preset references before evaluating
|
||||||
|
rules = self._expand_rule_presets(rules)
|
||||||
|
|
||||||
# Find all matching rules
|
# Find all matching rules
|
||||||
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
|
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
|
||||||
|
|
||||||
@@ -88,6 +95,37 @@ class FormattingEngine:
|
|||||||
|
|
||||||
return style, formatted_value
|
return style, formatted_value
|
||||||
|
|
||||||
|
def _expand_rule_presets(self, rules: list[FormatRule]) -> list[FormatRule]:
|
||||||
|
"""
|
||||||
|
Replace any FormatRule that references a rule preset with the preset's rules.
|
||||||
|
|
||||||
|
A rule is a rule preset reference when its formatter has a preset name
|
||||||
|
that exists in rule_presets (and not in formatter_presets).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rules: Original list of FormatRule
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Expanded list with preset references replaced by their FormatRules
|
||||||
|
"""
|
||||||
|
expanded = []
|
||||||
|
for rule in rules:
|
||||||
|
preset_name = self._get_rule_preset_name(rule)
|
||||||
|
if preset_name:
|
||||||
|
expanded.extend(self._rule_presets[preset_name].rules)
|
||||||
|
else:
|
||||||
|
expanded.append(rule)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
|
||||||
|
"""Return the preset name if the rule's formatter references a rule preset, else None."""
|
||||||
|
if rule.formatter is None:
|
||||||
|
return None
|
||||||
|
preset = getattr(rule.formatter, "preset", None)
|
||||||
|
if preset and preset in self._rule_presets:
|
||||||
|
return preset
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_matching_rules(
|
def _get_matching_rules(
|
||||||
self,
|
self,
|
||||||
rules: list[FormatRule],
|
rules: list[FormatRule],
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from myfasthtml.core.formatting.dataclasses import (
|
||||||
|
Condition, Style, FormatRule, RulePreset,
|
||||||
|
NumberFormatter, )
|
||||||
|
|
||||||
# === Style Presets (DaisyUI 5) ===
|
# === Style Presets (DaisyUI 5) ===
|
||||||
# Keys use CSS property names (with hyphens)
|
# Keys use CSS property names (with hyphens)
|
||||||
|
|
||||||
@@ -22,6 +26,56 @@ DEFAULT_STYLE_PRESETS = {
|
|||||||
"white": {"__class__": "mf-formatting-white", },
|
"white": {"__class__": "mf-formatting-white", },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# === Rule Presets ===
|
||||||
|
# Each RulePreset = name + description + list of FormatRule objects.
|
||||||
|
# Referenced in DSL as: format("name") or style("name")
|
||||||
|
|
||||||
|
DEFAULT_RULE_PRESETS: dict[str, RulePreset] = {
|
||||||
|
"accounting": RulePreset(
|
||||||
|
name="accounting",
|
||||||
|
description="Negatives in parentheses, positives plain",
|
||||||
|
rules=[
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
formatter=NumberFormatter(precision=0, prefix="(", suffix=")",
|
||||||
|
absolute=True, thousands_sep=" "),
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator=">", value=0),
|
||||||
|
formatter=NumberFormatter(precision=0, thousands_sep=" "),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"traffic_light": RulePreset(
|
||||||
|
name="traffic_light",
|
||||||
|
description="Red / yellow / green style based on sign",
|
||||||
|
rules=[
|
||||||
|
FormatRule(condition=Condition(operator="<", value=0), style=Style(preset="error")),
|
||||||
|
FormatRule(condition=Condition(operator="==", value=0), style=Style(preset="warning")),
|
||||||
|
FormatRule(condition=Condition(operator=">", value=0), style=Style(preset="success")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"budget_variance": RulePreset(
|
||||||
|
name="budget_variance",
|
||||||
|
description="% variance: negative=error, over 10%=warning, else plain",
|
||||||
|
rules=[
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
|
||||||
|
style=Style(preset="error"),
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator=">", value=0.1),
|
||||||
|
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
|
||||||
|
style=Style(preset="warning"),
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
formatter=NumberFormatter(precision=1, suffix="%", multiplier=100),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# === Formatter Presets ===
|
# === Formatter Presets ===
|
||||||
|
|
||||||
DEFAULT_FORMATTER_PRESETS = {
|
DEFAULT_FORMATTER_PRESETS = {
|
||||||
|
|||||||
167
tests/core/formatting/test_rule_presets.py
Normal file
167
tests/core/formatting/test_rule_presets.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tests for DEFAULT_RULE_PRESETS expansion in FormattingEngine."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import FormatRule, NumberFormatter, RulePreset
|
||||||
|
from myfasthtml.core.formatting.engine import FormattingEngine
|
||||||
|
from myfasthtml.core.formatting.style_resolver import StyleContainer
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def make_preset_rule(preset_name: str) -> list[FormatRule]:
|
||||||
|
"""Simulate what format("preset_name") produces from the DSL transformer."""
|
||||||
|
return [FormatRule(formatter=NumberFormatter(preset=preset_name))]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# accounting preset
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_i_can_use_accounting_preset_with_negative_value():
|
||||||
|
"""Negative value → parentheses format, no style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("accounting")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-12500)
|
||||||
|
|
||||||
|
assert formatted == "(12 500)"
|
||||||
|
assert css is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_accounting_preset_with_positive_value():
|
||||||
|
"""Positive value → plain number format, no style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("accounting")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=8340)
|
||||||
|
|
||||||
|
assert formatted == "8 340"
|
||||||
|
assert css is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_accounting_preset_with_zero():
|
||||||
|
"""Zero does not match < 0 or > 0 → no formatter, no style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("accounting")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=0)
|
||||||
|
|
||||||
|
assert formatted is None
|
||||||
|
assert css is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# traffic_light preset
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_i_can_use_traffic_light_preset_with_negative_value():
|
||||||
|
"""Negative value → error style (red)."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("traffic_light")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-1)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert css.cls == "mf-formatting-error"
|
||||||
|
assert formatted is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_traffic_light_preset_with_zero():
|
||||||
|
"""Zero → warning style (yellow)."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("traffic_light")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=0)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert css.cls == "mf-formatting-warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_traffic_light_preset_with_positive_value():
|
||||||
|
"""Positive value → success style (green)."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("traffic_light")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert css.cls == "mf-formatting-success"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# budget_variance preset
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_i_can_use_budget_variance_preset_with_negative_value():
|
||||||
|
"""Negative variance → percentage format + error style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("budget_variance")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-0.08)
|
||||||
|
|
||||||
|
assert formatted == "-8.0%"
|
||||||
|
assert css is not None
|
||||||
|
assert css.cls == "mf-formatting-error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_budget_variance_preset_over_threshold():
|
||||||
|
"""Variance > 10% → percentage format + warning style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("budget_variance")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=0.15)
|
||||||
|
|
||||||
|
assert formatted == "15.0%"
|
||||||
|
assert css is not None
|
||||||
|
assert css.cls == "mf-formatting-warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_budget_variance_preset_within_range():
|
||||||
|
"""Variance within 0–10% → percentage format, no special style."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("budget_variance")
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=0.05)
|
||||||
|
|
||||||
|
assert formatted == "5.0%"
|
||||||
|
assert css is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Unknown preset → no expansion, engine falls back gracefully
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_i_cannot_use_unknown_rule_preset():
|
||||||
|
"""Unknown preset name is not expanded — treated as plain formatter preset (no crash)."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = make_preset_rule("nonexistent_preset")
|
||||||
|
|
||||||
|
# Should not raise; returns defaults (no special formatting)
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert css is None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Custom rule_presets override
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_i_can_pass_custom_rule_presets():
|
||||||
|
"""Engine accepts custom rule_presets dict of RulePreset, overriding defaults."""
|
||||||
|
custom = {
|
||||||
|
"my_preset": RulePreset(
|
||||||
|
name="my_preset",
|
||||||
|
description="Custom test preset",
|
||||||
|
rules=[
|
||||||
|
FormatRule(formatter=NumberFormatter(precision=2, suffix=" pts")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
engine = FormattingEngine(rule_presets=custom)
|
||||||
|
rules = make_preset_rule("my_preset")
|
||||||
|
|
||||||
|
_, formatted = engine.apply_format(rules, cell_value=9.5)
|
||||||
|
|
||||||
|
assert formatted == "9.50 pts"
|
||||||
Reference in New Issue
Block a user