thatcher 1.0.80 → 1.0.82

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.80",
3
+ "version": "1.0.82",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -342,6 +342,7 @@ export function customEntityDefToEntityDef(row) {
342
342
  required: !!f.required,
343
343
  options: f.options || undefined,
344
344
  ref: f.ref || undefined,
345
+ cross_entity_rule: f.cross_entity_rule || undefined,
345
346
  };
346
347
  }
347
348
  return {
@@ -218,6 +218,79 @@ function checkContractDateOrder(entityName, data, existingRecord) {
218
218
  return null;
219
219
  }
220
220
 
221
+ const CROSS_ENTITY_OPERATORS = {
222
+ equals: (a, b) => a === b,
223
+ not_equals: (a, b) => a !== b,
224
+ gt: (a, b) => Number(a) > Number(b),
225
+ gte: (a, b) => Number(a) >= Number(b),
226
+ lt: (a, b) => Number(a) < Number(b),
227
+ lte: (a, b) => Number(a) <= Number(b),
228
+ };
229
+
230
+ // Generic cross-entity rule evaluator: reads a field's cross_entity_rule
231
+ // definition off the SAME spec.fields[key] shape every other field metadata
232
+ // (visible_to/editable_by/encrypted) already lives on, so it works for any
233
+ // entity -- built-in or custom_entity_def-registered -- carrying one, not
234
+ // hardcoded to a single entity name the way checkStockBalance/
235
+ // checkContractDateOrder/checkResourceCapacity are. Two shapes:
236
+ // - simple: {ref_field, related_field, operator, value} compares the
237
+ // related record's field against a literal value.
238
+ // - aggregate: {ref_field, aggregate:'sum', aggregate_field, operator,
239
+ // limit_field} sums a field across every OTHER record referencing the
240
+ // same related entity via ref_field, and compares against a limit read
241
+ // from the related record's limit_field.
242
+ // Uses the same unscoped internal get()/list() lookup pattern
243
+ // checkContractDateOrder/checkStockBalance/checkResourceCapacity already
244
+ // use -- these run from validateEntity/validateUpdate, which today has no
245
+ // user context threaded through, so this does not introduce a new
246
+ // access-control gap relative to those three existing checks; it matches
247
+ // their established behavior exactly rather than inventing a new one.
248
+ async function checkCrossEntityRules(entityName, data, existingRecord) {
249
+ const spec = getSpec(entityName);
250
+ for (const [fieldKey, fieldDef] of Object.entries(spec.fields || {})) {
251
+ const rule = fieldDef.cross_entity_rule;
252
+ if (!rule || !rule.ref_field) continue;
253
+
254
+ const refId = data[rule.ref_field] !== undefined ? data[rule.ref_field] : existingRecord?.[rule.ref_field];
255
+ if (refId == null) continue;
256
+
257
+ const refFieldDef = spec.fields[rule.ref_field];
258
+ const relatedEntity = refFieldDef?.ref;
259
+ if (!relatedEntity) continue;
260
+
261
+ const { get, list } = await import('@/lib/busybase/store');
262
+ const related = await get(relatedEntity, refId);
263
+ if (!related) continue;
264
+
265
+ const operatorFn = CROSS_ENTITY_OPERATORS[rule.operator];
266
+ if (!operatorFn) continue;
267
+
268
+ if (rule.aggregate === 'sum') {
269
+ const relatedEntitySelfEntity = entityName;
270
+ const siblingRecords = await list(relatedEntitySelfEntity, { [rule.ref_field]: refId });
271
+ const selfId = existingRecord?.id;
272
+ const thisValue = Number(data[rule.aggregate_field] !== undefined ? data[rule.aggregate_field] : existingRecord?.[rule.aggregate_field]) || 0;
273
+ const othersTotal = siblingRecords
274
+ .filter(r => r.id !== selfId)
275
+ .reduce((sum, r) => sum + (Number(r[rule.aggregate_field]) || 0), 0);
276
+ const resultingTotal = othersTotal + thisValue;
277
+ const limit = Number(related[rule.limit_field]);
278
+ if (!Number.isFinite(limit)) continue;
279
+ if (!operatorFn(resultingTotal, limit)) {
280
+ return `${fieldKey}: sum of ${rule.aggregate_field} across ${relatedEntitySelfEntity} for this ${rule.ref_field} would be ${resultingTotal}, violating ${rule.operator} ${limit}`;
281
+ }
282
+ continue;
283
+ }
284
+
285
+ if (!rule.related_field) continue;
286
+ const relatedValue = related[rule.related_field];
287
+ if (!operatorFn(relatedValue, rule.value)) {
288
+ return `${fieldKey}: related ${relatedEntity}.${rule.related_field} (${relatedValue}) fails rule "${rule.operator} ${rule.value}"`;
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
221
294
  const WEEKLY_CAPACITY_HOURS = 40;
222
295
 
223
296
  // Two date ranges overlap unless one entirely precedes the other -- the
@@ -288,6 +361,9 @@ export async function validateEntity(entityName, data, existingRecord = null, op
288
361
  const capacityErr = await checkResourceCapacity(entityName, data, existingRecord);
289
362
  if (capacityErr) errors.allocated_hours_per_week = capacityErr;
290
363
 
364
+ const crossEntityErr = await checkCrossEntityRules(entityName, data, existingRecord);
365
+ if (crossEntityErr) errors._cross_entity = crossEntityErr;
366
+
291
367
  return errors;
292
368
  }
293
369
 
@@ -369,6 +445,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
369
445
  const capacityErr = await checkResourceCapacity(entityName, changes, existingRecord);
370
446
  if (capacityErr) errors.allocated_hours_per_week = capacityErr;
371
447
 
448
+ const crossEntityErr = await checkCrossEntityRules(entityName, changes, existingRecord);
449
+ if (crossEntityErr) errors._cross_entity = crossEntityErr;
450
+
372
451
  return errors;
373
452
  }
374
453
 
@@ -44,12 +44,26 @@ export function renderCustomEntityList(user, defs) {
44
44
  var idx=fieldRowCount++;
45
45
  var row=document.createElement('div');
46
46
  row.className='field-row';
47
- row.style.cssText='display:flex;gap:6px;margin:4px 0';
48
- row.innerHTML='<input type="text" class="form-input field-key" placeholder="key" style="flex:1">'+
47
+ row.style.cssText='display:flex;flex-direction:column;gap:4px;margin:4px 0;padding:6px;border:1px solid var(--color-border,#eee);border-radius:4px';
48
+ row.innerHTML='<div style="display:flex;gap:6px">'+
49
+ '<input type="text" class="form-input field-key" placeholder="key" style="flex:1">'+
49
50
  '<select class="form-input field-type" style="flex:1">${typeOpts}</select>'+
50
51
  '<input type="text" class="form-input field-label" placeholder="label" style="flex:1">'+
51
52
  '<label style="display:flex;align-items:center;gap:4px;font-size:12px"><input type="checkbox" class="field-required">required</label>'+
52
- '<button type="button" class="btn-ghost-clean" data-action="removeFieldRow">x</button>';
53
+ '<button type="button" class="btn-ghost-clean" data-action="removeFieldRow">x</button>'+
54
+ '</div>'+
55
+ '<details><summary style="font-size:12px;cursor:pointer">Cross-entity rule (optional)</summary>'+
56
+ '<div style="display:flex;gap:6px;margin-top:4px;flex-wrap:wrap">'+
57
+ '<input type="text" class="form-input rule-ref-field" placeholder="ref field, e.g. project_id" style="flex:1;min-width:120px">'+
58
+ '<input type="text" class="form-input rule-related-field" placeholder="related field, e.g. status" style="flex:1;min-width:120px">'+
59
+ '<select class="form-input rule-operator" style="flex:1;min-width:100px">'+
60
+ '<option value="">No rule</option><option value="equals">equals</option><option value="not_equals">not_equals</option>'+
61
+ '<option value="gt">gt</option><option value="gte">gte</option><option value="lt">lt</option><option value="lte">lte</option>'+
62
+ '</select>'+
63
+ '<input type="text" class="form-input rule-value" placeholder="value (simple rule)" style="flex:1;min-width:100px">'+
64
+ '<input type="text" class="form-input rule-aggregate-field" placeholder="aggregate field (sum, optional)" style="flex:1;min-width:140px">'+
65
+ '<input type="text" class="form-input rule-limit-field" placeholder="limit field on related entity (optional)" style="flex:1;min-width:160px">'+
66
+ '</div></details>';
53
67
  container.appendChild(row);
54
68
  }
55
69
  window.addFieldRow=addFieldRow;
@@ -69,7 +83,23 @@ export function renderCustomEntityList(user, defs) {
69
83
  var type=row.querySelector('.field-type').value;
70
84
  var flabel=row.querySelector('.field-label').value.trim();
71
85
  var required=row.querySelector('.field-required').checked;
72
- if(key)fields.push({key:key,type:type,label:flabel||key,required:required});
86
+ if(!key)return;
87
+ var field={key:key,type:type,label:flabel||key,required:required};
88
+ var ruleOperator=row.querySelector('.rule-operator').value;
89
+ if(ruleOperator){
90
+ var refField=row.querySelector('.rule-ref-field').value.trim();
91
+ var relatedField=row.querySelector('.rule-related-field').value.trim();
92
+ var aggregateField=row.querySelector('.rule-aggregate-field').value.trim();
93
+ var limitField=row.querySelector('.rule-limit-field').value.trim();
94
+ if(refField){
95
+ if(aggregateField&&limitField){
96
+ field.cross_entity_rule={ref_field:refField,aggregate:'sum',aggregate_field:aggregateField,operator:ruleOperator,limit_field:limitField};
97
+ } else if(relatedField){
98
+ field.cross_entity_rule={ref_field:refField,related_field:relatedField,operator:ruleOperator,value:row.querySelector('.rule-value').value};
99
+ }
100
+ }
101
+ }
102
+ fields.push(field);
73
103
  });
74
104
  if(!fields.length){status.textContent='At least one field required';return}
75
105
  status.textContent='Creating...';
@@ -354,6 +354,31 @@ html, body { overflow-x: hidden; max-width: 100%; }
354
354
  .form-hint { font-size: 0.75rem; }
355
355
  }
356
356
 
357
+ /* Board/Kanban view: unstyled by default (relied on browser block/flow layout
358
+ accidentally being usable on wide desktop screens) -- explicit rules needed
359
+ so it doesn't collapse into an unreadable single column of stacked cards on
360
+ any width. */
361
+ .board-view { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 8px; }
362
+ .board-column { flex: 0 0 280px; min-width: 280px; background: var(--color-bg, #f8fafc); border-radius: var(--radius, 8px); display: flex; flex-direction: column; max-height: 80vh; }
363
+ .board-column-header { display: flex; justify-content: space-between; align-items: center; padding: 12px; font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-muted); }
364
+ .board-column-count { background: var(--color-border, #e2e8f0); border-radius: 9999px; padding: 2px 8px; font-size: 12px; }
365
+ .board-column-body { flex: 1; overflow-y: auto; padding: 0 8px 8px; display: flex; flex-direction: column; gap: 8px; }
366
+ .board-column-empty { padding: 16px; text-align: center; color: var(--color-text-muted); font-size: 13px; }
367
+ .board-card { background: var(--color-surface, #fff); border: 1px solid var(--color-border, #e2e8f0); border-radius: 6px; padding: 10px 12px; cursor: pointer; }
368
+ .board-card-title { font-size: 14px; font-weight: 500; }
369
+ .board-card-meta { margin-top: 4px; font-size: 12px; color: var(--color-text-muted); }
370
+ .board-empty-state { padding: 24px; text-align: center; color: var(--color-text-muted); }
371
+
372
+ /* Kanban stays horizontally-scrolling (not stacked) on narrow viewports --
373
+ stacking would hide every column but the first, losing the at-a-glance
374
+ status overview that is the entire point of a board view. Columns shrink
375
+ slightly and snap-scroll for a touch-friendly swipe between stages. */
376
+ @media (max-width: 768px) {
377
+ .board-view { scroll-snap-type: x mandatory; margin-left: calc(-1 * var(--page-gutter)); margin-right: calc(-1 * var(--page-gutter)); padding-left: var(--page-gutter); padding-right: var(--page-gutter); }
378
+ .board-column { flex-basis: 85vw; min-width: 240px; scroll-snap-align: start; }
379
+ .board-card { min-height: 44px; }
380
+ }
381
+
357
382
  /* Tablet */
358
383
  @media (min-width: 769px) and (max-width: 1023px) {
359
384
  :root { --page-gutter: 1.25rem; }