thatcher 1.0.48 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +3 -0
- package/src/lib/automation-engine.js +113 -0
- package/src/lib/field-types.js +45 -0
- package/src/ui/board-view-renderer.js +76 -0
- package/src/ui/calendar-view-renderer.js +196 -0
- package/src/ui/grid-view-renderer.js +78 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -63,6 +63,9 @@ export class Thatcher {
|
|
|
63
63
|
// Load plugins
|
|
64
64
|
await this.loadPlugins();
|
|
65
65
|
|
|
66
|
+
const { registerAutomationEngine } = await import(resolveModule('./lib/automation-engine.js'));
|
|
67
|
+
registerAutomationEngine();
|
|
68
|
+
|
|
66
69
|
// Hot reload
|
|
67
70
|
if (this.options.server.hotReload) {
|
|
68
71
|
this.setupHotReload();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { hookEngine } from './hook-engine.js';
|
|
2
|
+
import { update as updateRecord, create as createRecord } from './busybase/store.js';
|
|
3
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('[AutomationEngine]');
|
|
7
|
+
|
|
8
|
+
const OPERATORS = {
|
|
9
|
+
eq: (a, b) => a === b,
|
|
10
|
+
neq: (a, b) => a !== b,
|
|
11
|
+
gt: (a, b) => Number(a) > Number(b),
|
|
12
|
+
gte: (a, b) => Number(a) >= Number(b),
|
|
13
|
+
lt: (a, b) => Number(a) < Number(b),
|
|
14
|
+
lte: (a, b) => Number(a) <= Number(b),
|
|
15
|
+
in: (a, b) => Array.isArray(b) && b.includes(a),
|
|
16
|
+
changed: (a, b, prev, field) => prev && prev[field] !== a,
|
|
17
|
+
present: (a) => a !== null && a !== undefined && a !== '',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function evalCondition(cond, data, prev) {
|
|
21
|
+
const { field, op = 'eq', value } = cond;
|
|
22
|
+
const actual = data[field];
|
|
23
|
+
const fn = OPERATORS[op];
|
|
24
|
+
if (!fn) { log.error(`unknown automation operator: ${op}`); return false; }
|
|
25
|
+
return fn(actual, value, prev, field);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function evalConditions(conditions, data, prev) {
|
|
29
|
+
if (!conditions || !conditions.length) return true;
|
|
30
|
+
return conditions.every(c => evalCondition(c, data, prev));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runAction(action, context) {
|
|
34
|
+
const { entity, id, data, user } = context;
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case 'set_field': {
|
|
37
|
+
const patch = { [action.field]: action.value };
|
|
38
|
+
await updateRecord(entity, id, patch, user);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case 'create_entity': {
|
|
42
|
+
const payload = { ...(action.data || {}) };
|
|
43
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
44
|
+
if (typeof v === 'string' && v.startsWith('$')) payload[k] = data[v.slice(1)];
|
|
45
|
+
}
|
|
46
|
+
await createRecord(action.entity, payload, user);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case 'notify': {
|
|
50
|
+
await hookEngine.execute('automation:notify', { entity, id, data, user, message: action.message, target: action.target });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
log.error(`unknown automation action type: ${action.type}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeContext(context) {
|
|
59
|
+
const data = context.data || context.record || {};
|
|
60
|
+
const prev = context.before || context.prev || null;
|
|
61
|
+
return { ...context, data, prev, entity: context.entity, id: context.id ?? data.id };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runRule(rule, rawContext) {
|
|
65
|
+
const context = normalizeContext(rawContext);
|
|
66
|
+
if (!evalConditions(rule.when, context.data, context.prev)) return;
|
|
67
|
+
for (const action of rule.then || []) {
|
|
68
|
+
try {
|
|
69
|
+
await runAction(action, context);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
log.error(`automation rule "${rule.id || rule.name}" action failed:`, { message: error.message });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getRules(entityName, trigger) {
|
|
77
|
+
let config;
|
|
78
|
+
try {
|
|
79
|
+
config = getConfigEngineSync().getConfig();
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const rules = config?.automation?.rules || [];
|
|
84
|
+
return rules.filter(r => (!r.entity || r.entity === entityName) && (!r.trigger || r.trigger === trigger));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let registered = false;
|
|
88
|
+
|
|
89
|
+
export function registerAutomationEngine() {
|
|
90
|
+
if (registered) return;
|
|
91
|
+
registered = true;
|
|
92
|
+
|
|
93
|
+
const config = getConfigEngineSync().getConfig();
|
|
94
|
+
const entityNames = Object.keys(config?.entities || {});
|
|
95
|
+
|
|
96
|
+
for (const entityName of entityNames) {
|
|
97
|
+
for (const trigger of ['create', 'update', 'delete']) {
|
|
98
|
+
hookEngine.register(`${trigger}:${entityName}:after`, async (context) => {
|
|
99
|
+
for (const rule of getRules(entityName, trigger)) await runRule(rule, context);
|
|
100
|
+
return context;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
hookEngine.register(`transition:${entityName}`, async (context) => {
|
|
104
|
+
for (const rule of getRules(entityName, 'transition')) await runRule(rule, context);
|
|
105
|
+
return context;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function dispatchAutomation(trigger, entity, context) {
|
|
111
|
+
const rules = getRules(entity, trigger);
|
|
112
|
+
return Promise.all(rules.map(rule => runRule(rule, context)));
|
|
113
|
+
}
|
package/src/lib/field-types.js
CHANGED
|
@@ -124,6 +124,51 @@ export const fieldRegistry = {
|
|
|
124
124
|
}),
|
|
125
125
|
image: createSimpleType('TEXT'),
|
|
126
126
|
id: createSimpleType('TEXT PRIMARY KEY'),
|
|
127
|
+
people: createSimpleType('TEXT', {
|
|
128
|
+
coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
|
|
129
|
+
format: (val, field, spec, row) => {
|
|
130
|
+
const ids = Array.isArray(val) ? val : (val ? [val] : []);
|
|
131
|
+
if (!ids.length) return null;
|
|
132
|
+
return ids.map(id => row?.[`${id}_display`] || row?.assignee_names?.[id] || String(id));
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
link: createSimpleType('TEXT', {
|
|
136
|
+
format: (val) => String(val ?? ''),
|
|
137
|
+
isValid: (val) => { if (!val) return true; try { new URL(String(val)); return true; } catch { return false; } },
|
|
138
|
+
}),
|
|
139
|
+
file: createSimpleType('TEXT', {
|
|
140
|
+
coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
|
|
141
|
+
format: (val) => {
|
|
142
|
+
if (!val) return null;
|
|
143
|
+
const obj = typeof val === 'string' ? (() => { try { return JSON.parse(val); } catch { return { name: val }; } })() : val;
|
|
144
|
+
return { name: obj.name || obj.filename || 'file', url: obj.url || null };
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
rating: createNumberType('INTEGER', (val) => {
|
|
148
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
149
|
+
const num = parseInt(val, 10);
|
|
150
|
+
if (isNaN(num)) throw new Error(`Invalid rating: ${val}`);
|
|
151
|
+
return num;
|
|
152
|
+
}, (val, field) => ({ value: Number(val) || 0, max: field?.max || 5 })),
|
|
153
|
+
progress: createNumberType('REAL', (val) => {
|
|
154
|
+
if (val === undefined || val === '' || val === null) return null;
|
|
155
|
+
const num = parseFloat(val);
|
|
156
|
+
if (isNaN(num)) throw new Error(`Invalid progress: ${val}`);
|
|
157
|
+
return num;
|
|
158
|
+
}, (val) => Math.max(0, Math.min(100, Number(val) || 0))),
|
|
159
|
+
checkbox: createSimpleType('INTEGER', {
|
|
160
|
+
coerce: (val) => (val === true || val === 'true' || val === 'on' || val === 1 ? 1 : 0),
|
|
161
|
+
format: (val) => !!val,
|
|
162
|
+
}),
|
|
163
|
+
formula: {
|
|
164
|
+
sqlType: 'TEXT',
|
|
165
|
+
coerce: () => undefined,
|
|
166
|
+
format: (val, field, spec, row) => {
|
|
167
|
+
if (typeof field?.compute !== 'function') return null;
|
|
168
|
+
try { return field.compute(row); } catch { return null; }
|
|
169
|
+
},
|
|
170
|
+
isValid: () => true,
|
|
171
|
+
},
|
|
127
172
|
};
|
|
128
173
|
|
|
129
174
|
export function getFieldHandler(type) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc, statusPill, TABLE_SCRIPT } from '@/ui/render-helpers.js';
|
|
3
|
+
import { getStateField, getStageLabels } from '@/lib/workflow-engine.js';
|
|
4
|
+
|
|
5
|
+
function cardTitleField(spec) {
|
|
6
|
+
if (spec.list?.titleField) return spec.list.titleField;
|
|
7
|
+
const candidates = ['name', 'title', 'label'];
|
|
8
|
+
for (const c of candidates) if (spec.fields?.[c]) return c;
|
|
9
|
+
const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
|
|
10
|
+
return first || 'id';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function boardCard(entityName, record, titleField) {
|
|
14
|
+
const title = esc(record[titleField] ?? record.id);
|
|
15
|
+
const status = record.status !== undefined ? statusPill(record.status) : '';
|
|
16
|
+
return `<div class="board-card" draggable="true" data-id="${esc(record.id)}" data-navigate="/${esc(entityName)}/${esc(record.id)}">
|
|
17
|
+
<div class="board-card-title">${title}</div>
|
|
18
|
+
${status ? `<div class="board-card-meta">${status}</div>` : ''}
|
|
19
|
+
</div>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function boardColumn(entityName, stageKey, stageLabel, records, titleField) {
|
|
23
|
+
const cards = records.map(r => boardCard(entityName, r, titleField)).join('') ||
|
|
24
|
+
`<div class="board-column-empty">No items</div>`;
|
|
25
|
+
return `<div class="board-column" data-stage="${esc(stageKey)}">
|
|
26
|
+
<div class="board-column-header"><span>${esc(stageLabel)}</span><span class="board-column-count">${records.length}</span></div>
|
|
27
|
+
<div class="board-column-body" data-drop-zone="${esc(stageKey)}">${cards}</div>
|
|
28
|
+
</div>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const BOARD_SCRIPT = `(function(){
|
|
32
|
+
document.addEventListener('dragstart',e=>{const c=e.target.closest('.board-card');if(!c)return;e.dataTransfer.setData('text/plain',c.dataset.id);c.classList.add('dragging')});
|
|
33
|
+
document.addEventListener('dragend',e=>{const c=e.target.closest('.board-card');if(c)c.classList.remove('dragging')});
|
|
34
|
+
document.addEventListener('dragover',e=>{const z=e.target.closest('[data-drop-zone]');if(z)e.preventDefault()});
|
|
35
|
+
document.addEventListener('drop',async e=>{
|
|
36
|
+
const zone=e.target.closest('[data-drop-zone]');if(!zone)return;e.preventDefault();
|
|
37
|
+
const id=e.dataTransfer.getData('text/plain');if(!id)return;
|
|
38
|
+
const toStage=zone.dataset.dropZone;
|
|
39
|
+
const board=document.getElementById('board-root');
|
|
40
|
+
const entity=board.dataset.entity, workflow=board.dataset.workflow;
|
|
41
|
+
try{
|
|
42
|
+
const r=await fetch('/api/'+entity+'/'+id+'/transition',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workflow,toState:toStage})});
|
|
43
|
+
if(r.ok)location.reload();else{const d=await r.json().catch(()=>({}));showToast(d.error||'Transition failed','error')}
|
|
44
|
+
}catch(err){showToast(err.message,'error')}
|
|
45
|
+
});
|
|
46
|
+
})();`;
|
|
47
|
+
|
|
48
|
+
export function renderBoardView(user, entityName, spec, records, options = {}) {
|
|
49
|
+
const workflowName = options.workflow || spec.workflow;
|
|
50
|
+
if (!workflowName) {
|
|
51
|
+
return page(user, `${spec.labelPlural || spec.label} | Board`, null,
|
|
52
|
+
`<div class="board-empty-state">This entity has no workflow configured; board view requires a <code>workflow</code> key on the entity spec.</div>`);
|
|
53
|
+
}
|
|
54
|
+
const stateField = getStateField(workflowName);
|
|
55
|
+
const stageLabels = getStageLabels(workflowName);
|
|
56
|
+
const titleField = cardTitleField(spec);
|
|
57
|
+
|
|
58
|
+
const grouped = {};
|
|
59
|
+
for (const key of Object.keys(stageLabels)) grouped[key] = [];
|
|
60
|
+
for (const r of records) {
|
|
61
|
+
const stage = r[stateField];
|
|
62
|
+
if (!grouped[stage]) grouped[stage] = [];
|
|
63
|
+
grouped[stage].push(r);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const columns = Object.entries(stageLabels)
|
|
67
|
+
.map(([key, label]) => boardColumn(entityName, key, label, grouped[key] || [], titleField))
|
|
68
|
+
.join('');
|
|
69
|
+
|
|
70
|
+
const content = `<div class="page-header">
|
|
71
|
+
<div><h1 class="page-title">${esc(spec.labelPlural || spec.label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
72
|
+
</div>
|
|
73
|
+
<div id="board-root" class="board-view" data-entity="${esc(entityName)}" data-workflow="${esc(workflowName)}">${columns}</div>`;
|
|
74
|
+
|
|
75
|
+
return page(user, `${spec.labelPlural || spec.label} | Board`, null, content, [TABLE_SCRIPT, BOARD_SCRIPT]);
|
|
76
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
import { getEntityLabel } from '@/config/spec-helpers.js';
|
|
4
|
+
|
|
5
|
+
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
6
|
+
const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
|
7
|
+
|
|
8
|
+
function calendarDateField(spec) {
|
|
9
|
+
if (spec.list?.dateField) return spec.list.dateField;
|
|
10
|
+
const entries = Object.entries(spec.fields || {});
|
|
11
|
+
const found = entries.find(([, f]) => f.type === 'date' || f.type === 'timestamp');
|
|
12
|
+
return found ? found[0] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toDateOrNull(value) {
|
|
16
|
+
if (value === null || value === undefined || value === '') return null;
|
|
17
|
+
const num = Number(value);
|
|
18
|
+
if (!isNaN(num) && num > 0) return new Date(num * 1000);
|
|
19
|
+
const d = new Date(value);
|
|
20
|
+
return isNaN(d) ? null : d;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dayKey(d) {
|
|
24
|
+
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calendarChip(entityName, record, titleField) {
|
|
28
|
+
const title = esc(record[titleField] ?? record.id);
|
|
29
|
+
return `<div class="calendar-chip" data-navigate="/${esc(entityName)}/${esc(record.id)}">${title}</div>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function calendarTitleField(spec) {
|
|
33
|
+
if (spec.list?.titleField) return spec.list.titleField;
|
|
34
|
+
const candidates = ['name', 'title', 'label'];
|
|
35
|
+
for (const c of candidates) if (spec.fields?.[c]) return c;
|
|
36
|
+
const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
|
|
37
|
+
return first || 'id';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderCalendarView(user, entityName, spec, records, options = {}) {
|
|
41
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
42
|
+
const dateField = calendarDateField(spec);
|
|
43
|
+
const titleField = calendarTitleField(spec);
|
|
44
|
+
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const year = Number(options.year) || now.getFullYear();
|
|
47
|
+
const month = options.month !== undefined ? Number(options.month) : now.getMonth();
|
|
48
|
+
|
|
49
|
+
const byDay = {};
|
|
50
|
+
const undated = [];
|
|
51
|
+
|
|
52
|
+
for (const r of records) {
|
|
53
|
+
const d = dateField ? toDateOrNull(r[dateField]) : null;
|
|
54
|
+
if (!d) { undated.push(r); continue; }
|
|
55
|
+
const key = dayKey(d);
|
|
56
|
+
if (!byDay[key]) byDay[key] = [];
|
|
57
|
+
byDay[key].push(r);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const firstOfMonth = new Date(year, month, 1);
|
|
61
|
+
const startWeekday = firstOfMonth.getDay();
|
|
62
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
63
|
+
|
|
64
|
+
const cells = [];
|
|
65
|
+
for (let i = 0; i < startWeekday; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
|
|
66
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
67
|
+
const key = `${year}-${month}-${day}`;
|
|
68
|
+
const dayRecords = byDay[key] || [];
|
|
69
|
+
const chips = dayRecords.map(r => calendarChip(entityName, r, titleField)).join('');
|
|
70
|
+
const isToday = year === now.getFullYear() && month === now.getMonth() && day === now.getDate();
|
|
71
|
+
cells.push(`<div class="calendar-cell${isToday ? ' calendar-cell-today' : ''}">
|
|
72
|
+
<div class="calendar-cell-date">${day}</div>
|
|
73
|
+
<div class="calendar-cell-chips">${chips}</div>
|
|
74
|
+
</div>`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const totalCells = startWeekday + daysInMonth;
|
|
78
|
+
const trailing = (7 - (totalCells % 7)) % 7;
|
|
79
|
+
for (let i = 0; i < trailing; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
|
|
80
|
+
|
|
81
|
+
const dayHeaders = DAY_NAMES.map(d => `<div class="calendar-day-header">${d}</div>`).join('');
|
|
82
|
+
|
|
83
|
+
let prevMonth = month - 1, prevYear = year;
|
|
84
|
+
if (prevMonth < 0) { prevMonth = 11; prevYear -= 1; }
|
|
85
|
+
let nextMonth = month + 1, nextYear = year;
|
|
86
|
+
if (nextMonth > 11) { nextMonth = 0; nextYear += 1; }
|
|
87
|
+
|
|
88
|
+
const undatedSection = undated.length
|
|
89
|
+
? `<div class="calendar-undated">
|
|
90
|
+
<h3>Undated (${undated.length})</h3>
|
|
91
|
+
<div class="calendar-undated-list">${undated.map(r => calendarChip(entityName, r, titleField)).join('')}</div>
|
|
92
|
+
</div>`
|
|
93
|
+
: '';
|
|
94
|
+
|
|
95
|
+
const noDateFieldNotice = !dateField
|
|
96
|
+
? `<div class="calendar-empty-state">This entity has no date field configured; all records are shown as undated.</div>`
|
|
97
|
+
: '';
|
|
98
|
+
|
|
99
|
+
const content = `<div class="page-header">
|
|
100
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
101
|
+
</div>
|
|
102
|
+
${noDateFieldNotice}
|
|
103
|
+
<div class="calendar-toolbar">
|
|
104
|
+
<a class="calendar-nav-link" href="?month=${prevMonth}&year=${prevYear}">« Prev</a>
|
|
105
|
+
<span class="calendar-month-label">${MONTH_NAMES[month]} ${year}</span>
|
|
106
|
+
<a class="calendar-nav-link" href="?month=${nextMonth}&year=${nextYear}">Next »</a>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="calendar-grid">
|
|
109
|
+
${dayHeaders}
|
|
110
|
+
${cells.join('')}
|
|
111
|
+
</div>
|
|
112
|
+
${undatedSection}`;
|
|
113
|
+
|
|
114
|
+
return page(user, `${label} | Calendar`, null, content);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function timelineFields(spec) {
|
|
118
|
+
if (spec.list?.timelineStart && spec.list?.timelineEnd) {
|
|
119
|
+
return [spec.list.timelineStart, spec.list.timelineEnd];
|
|
120
|
+
}
|
|
121
|
+
const fields = spec.fields || {};
|
|
122
|
+
const startCandidates = ['start_date', 'start'];
|
|
123
|
+
const endCandidates = ['end_date', 'due_date', 'end', 'deadline'];
|
|
124
|
+
const start = startCandidates.find(k => fields[k]) || null;
|
|
125
|
+
const end = endCandidates.find(k => fields[k]) || null;
|
|
126
|
+
return [start, end];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function timelineTitleField(spec) {
|
|
130
|
+
if (spec.list?.titleField) return spec.list.titleField;
|
|
131
|
+
const candidates = ['name', 'title', 'label'];
|
|
132
|
+
for (const c of candidates) if (spec.fields?.[c]) return c;
|
|
133
|
+
const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
|
|
134
|
+
return first || 'id';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderTimelineView(user, entityName, spec, records, options = {}) {
|
|
138
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
139
|
+
const [startField, endField] = timelineFields(spec);
|
|
140
|
+
const titleField = timelineTitleField(spec);
|
|
141
|
+
|
|
142
|
+
const plotted = [];
|
|
143
|
+
let missing = 0;
|
|
144
|
+
|
|
145
|
+
for (const r of records) {
|
|
146
|
+
const start = startField ? toDateOrNull(r[startField]) : null;
|
|
147
|
+
const end = endField ? toDateOrNull(r[endField]) : null;
|
|
148
|
+
if (!start || !end) { missing++; continue; }
|
|
149
|
+
plotted.push({ record: r, start, end });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!startField || !endField) {
|
|
153
|
+
return page(user, `${label} | Timeline`, null,
|
|
154
|
+
`<div class="page-header">
|
|
155
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="timeline-empty-state">This entity has no start/end date fields configured; timeline view requires <code>list.timelineStart</code>/<code>list.timelineEnd</code> or matching field names.</div>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const missingNotice = missing
|
|
161
|
+
? `<div class="timeline-missing-notice">${missing} item${missing === 1 ? '' : 's'} without dates not shown</div>`
|
|
162
|
+
: '';
|
|
163
|
+
|
|
164
|
+
if (!plotted.length) {
|
|
165
|
+
const content = `<div class="page-header">
|
|
166
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
167
|
+
</div>
|
|
168
|
+
${missingNotice}
|
|
169
|
+
<div class="timeline-empty-state">No items have both a start and end date.</div>`;
|
|
170
|
+
return page(user, `${label} | Timeline`, null, content);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const minTime = Math.min(...plotted.map(p => p.start.getTime()));
|
|
174
|
+
const maxTime = Math.max(...plotted.map(p => p.end.getTime()));
|
|
175
|
+
const span = Math.max(1, maxTime - minTime);
|
|
176
|
+
|
|
177
|
+
const rows = plotted.map(({ record, start, end }) => {
|
|
178
|
+
const title = esc(record[titleField] ?? record.id);
|
|
179
|
+
const offsetPct = ((start.getTime() - minTime) / span) * 100;
|
|
180
|
+
const widthPct = Math.max(1, ((end.getTime() - start.getTime()) / span) * 100);
|
|
181
|
+
return `<div class="timeline-row">
|
|
182
|
+
<div class="timeline-row-label">${title}</div>
|
|
183
|
+
<div class="timeline-row-track">
|
|
184
|
+
<div class="timeline-bar" data-navigate="/${esc(entityName)}/${esc(record.id)}" style="margin-left:${offsetPct}%;width:${widthPct}%"></div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>`;
|
|
187
|
+
}).join('');
|
|
188
|
+
|
|
189
|
+
const content = `<div class="page-header">
|
|
190
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
|
|
191
|
+
</div>
|
|
192
|
+
${missingNotice}
|
|
193
|
+
<div class="timeline-view">${rows}</div>`;
|
|
194
|
+
|
|
195
|
+
return page(user, `${label} | Timeline`, null, content);
|
|
196
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc, fmtVal, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
3
|
+
import { getDefaultSort, getAvailableFilters, getPageSize, getEntityLabel } from '@/config/spec-helpers.js';
|
|
4
|
+
|
|
5
|
+
const EMBEDDED_TYPES = new Set(['json', 'embedded']);
|
|
6
|
+
|
|
7
|
+
function getColumns(spec) {
|
|
8
|
+
const fields = spec?.fields || {};
|
|
9
|
+
const override = spec?.list?.columns;
|
|
10
|
+
if (Array.isArray(override) && override.length) {
|
|
11
|
+
return override
|
|
12
|
+
.filter(key => fields[key])
|
|
13
|
+
.map(key => [key, fields[key]]);
|
|
14
|
+
}
|
|
15
|
+
return Object.entries(fields).filter(([, f]) =>
|
|
16
|
+
!f.hidden && !EMBEDDED_TYPES.has(f.type)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isEditable(field) {
|
|
21
|
+
return field && field.readonly !== true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gridRow(entityName, item, columns) {
|
|
25
|
+
const cells = columns.map(([key, field]) => {
|
|
26
|
+
const value = item[key];
|
|
27
|
+
const rendered = fmtVal(value, key, item);
|
|
28
|
+
const editableAttr = isEditable(field) ? ` data-editable="${esc(key)}"` : '';
|
|
29
|
+
return `<td data-col="${esc(key)}"${editableAttr}>${rendered}</td>`;
|
|
30
|
+
}).join('');
|
|
31
|
+
return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${cells}</tr>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderGridView(user, entityName, spec, records, options = {}) {
|
|
35
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
36
|
+
const columns = getColumns(spec);
|
|
37
|
+
const defaultSort = getDefaultSort(spec);
|
|
38
|
+
const filters = getAvailableFilters(spec);
|
|
39
|
+
const pageSize = getPageSize(spec);
|
|
40
|
+
|
|
41
|
+
const headerCells = columns.map(([key, field]) => {
|
|
42
|
+
const colLabel = esc(field?.label || key);
|
|
43
|
+
const isDefaultSort = key === defaultSort.field;
|
|
44
|
+
return `<th data-sort="${esc(key)}" aria-label="Sort by ${colLabel}"${isDefaultSort ? ` class="sort-${esc(defaultSort.dir)}"` : ''}>${colLabel}</th>`;
|
|
45
|
+
}).join('');
|
|
46
|
+
|
|
47
|
+
const filterControls = filters.map(f => {
|
|
48
|
+
const fieldKey = typeof f === 'string' ? f : f.field;
|
|
49
|
+
const fieldSpec = spec.fields?.[fieldKey];
|
|
50
|
+
const filterLabel = esc(fieldSpec?.label || fieldKey);
|
|
51
|
+
const opts = (fieldSpec?.options || f.options || []).map(o => {
|
|
52
|
+
const value = typeof o === 'string' ? o : o.value;
|
|
53
|
+
const optLabel = typeof o === 'string' ? o : (o.label || o.value);
|
|
54
|
+
return `<option value="${esc(value)}">${esc(optLabel)}</option>`;
|
|
55
|
+
}).join('');
|
|
56
|
+
return `<div class="table-filter"><select data-filter="${esc(fieldKey)}" id="filter-${esc(fieldKey)}" aria-label="Filter by ${filterLabel}"><option value="">All ${filterLabel}</option>${opts}</select></div>`;
|
|
57
|
+
}).join('');
|
|
58
|
+
|
|
59
|
+
const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
|
|
60
|
+
emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
|
|
61
|
+
|
|
62
|
+
const content = `<div class="page-header">
|
|
63
|
+
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="table-wrap">
|
|
66
|
+
<div class="table-toolbar">
|
|
67
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
|
|
68
|
+
${filterControls}
|
|
69
|
+
<span class="table-count" id="row-count">${records.length} items</span>
|
|
70
|
+
</div>
|
|
71
|
+
<table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
|
|
72
|
+
<thead><tr>${headerCells}</tr></thead>
|
|
73
|
+
<tbody>${rows}</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</div>`;
|
|
76
|
+
|
|
77
|
+
return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT]);
|
|
78
|
+
}
|