json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
315 lines (274 loc) • 10.9 kB
JavaScript
class JoeWorkflowWidget extends HTMLElement {
constructor() {
super();
this.updateTimer = null;
this._rendering = false;
this._renderScheduled = false;
this.config = this.getDefaultConfig();
this.joeIndex = null;
this.widgetFieldName = null;
this._liveObj = null; // constructed snapshot for live completion when autoUpdate enabled
}
static get observedAttributes() {
return ['schema', '_id'];
}
connectedCallback() {
this.schemaName = this.getAttribute('schema');
this.objectId = this.getAttribute('_id');
this.classList.add('joe-workflow-widget');
// Get workflow config from field definition
this.workflowField = this.closest('.joe-object-field');
if (this.workflowField) {
var fieldName = this.workflowField.getAttribute('data-name');
this.widgetFieldName = fieldName;
if (fieldName && window._joe) {
var fieldDef = window._joe.getField(fieldName);
this.config = (fieldDef && fieldDef.workflow_config) || this.getDefaultConfig();
}
}
this.config = this.config || this.getDefaultConfig();
// Determine joeIndex for navigation and updates
try{
var overlay = this.closest('.joe-overlay');
if(overlay && overlay.getAttribute('data-joeindex') !== null){
this.joeIndex = parseInt(overlay.getAttribute('data-joeindex'), 10);
if(isNaN(this.joeIndex)){ this.joeIndex = null; }
}
}catch(e){}
// Get schema name from JOE if not provided
if (!this.schemaName && window._joe && window._joe.current && window._joe.current.schema) {
this.schemaName = window._joe.current.schema.__schemaname || window._joe.current.schema.name;
}
this.scheduleRender();
this.setupUpdateListener();
}
getDefaultConfig() {
return {
sections: 'all',
fields: 'all',
excludeSections: ['system'],
excludeFields: ['_id', 'created', 'joeUpdated', 'itemtype', 'tags', 'status'],
mustBeTrue: [],
autoUpdate: false // Disabled by default to prevent lockups
};
}
setupUpdateListener() {
// Only set up listeners if autoUpdate is enabled
if (!this.config || !this.config.autoUpdate) {
return;
}
// Simplest live update (no tech debt):
// On change, compute a constructed snapshot once (debounced) and re-render from that.
// Critically, we never call _jco(true) from render() to avoid re-entrancy loops.
if(this.joeIndex === null || !window.getJoe){ return; }
var self = this;
var overlay = this.closest('.joe-overlay');
if(!overlay){ return; }
this._onFormChange = function(ev){
if(ev && ev.target && self.contains(ev.target)){ return; }
clearTimeout(self.updateTimer);
self.updateTimer = setTimeout(function(){
try{
var j = getJoe(self.joeIndex);
self._liveObj = (j && j.constructObjectFromFields) ? j.constructObjectFromFields(self.joeIndex) : null;
}catch(e){
self._liveObj = null;
}
self.scheduleRender();
}, 350);
};
overlay.addEventListener('change', this._onFormChange, true);
}
disconnectedCallback() {
try{
var overlay = this.closest('.joe-overlay');
if(overlay && this._onFormChange){
overlay.removeEventListener('change', this._onFormChange, true);
}
}catch(e){}
if (this.updateTimer) {
clearTimeout(this.updateTimer);
}
}
attributeChangedCallback(attr, oldValue, newValue) {
if (oldValue !== newValue) {
if (attr === 'schema') {
this.schemaName = newValue;
} else if (attr === '_id') {
this.objectId = newValue;
}
this.scheduleRender();
}
}
scheduleRender() {
if (this._renderScheduled) {
return;
}
this._renderScheduled = true;
var self = this;
// Defer to avoid re-entrancy with JOE render/construct cycles
setTimeout(function() {
self._renderScheduled = false;
self.render();
}, 0);
}
render() {
var self = this;
if (this._rendering) {
return;
}
this._rendering = true;
var joe = window._joe;
if (!joe || !joe.current) {
this.innerHTML = '<div class="joe-workflow-error">Workflow widget requires active JOE instance</div>';
this._rendering = false;
return;
}
var fields = joe.current.fields || [];
var sections = joe.current.sections || {};
// IMPORTANT: Do NOT call _jco(true) here. It triggers constructObjectFromFields which can
// cause re-entrant rerenders / attributeChangedCallback loops and lock up the UI.
// Use the live object snapshot. (When autoUpdate is re-enabled later, we can switch to a safer source.)
var currentObj = this._liveObj || joe.current.object || {};
// Filter sections to show
var sectionsToShow = [];
if (this.config.sections === 'all') {
for (var secId in sections) {
if (this.config.excludeSections && this.config.excludeSections.indexOf(secId) !== -1) continue;
if (secId === 'system') continue;
if (!sections[secId] || !sections[secId].fields || !sections[secId].fields.length) continue;
sectionsToShow.push(secId);
}
} else if (Array.isArray(this.config.sections)) {
sectionsToShow = this.config.sections.filter(function(secId) {
return sections[secId] &&
(!self.config.excludeSections || self.config.excludeSections.indexOf(secId) === -1);
});
}
// Calculate progress for each section
var sectionProgress = {};
sectionsToShow.forEach(function(sectionId) {
var section = sections[sectionId];
if (!section) return;
var fieldsInSection = section.fields || [];
if (fieldsInSection.length === 0) return;
var completed = 0;
var totalCount = 0;
var missing = [];
fieldsInSection.forEach(function(fname) {
var field = joe.getField(fname) || { name: fname, type: 'text' };
var ftype = (joe.propAsFuncOrValue && joe.propAsFuncOrValue(field.type, currentObj)) || field.type || 'text';
ftype = (ftype || '').toString().toLowerCase();
// Never include content fields in completion counts (widgets, labels, etc).
if(ftype === 'content'){ return; }
if(self.config.excludeFields && self.config.excludeFields.indexOf(field.name) !== -1){ return; }
if(self.config.fields === 'requiredOnly' && !joe.propAsFuncOrValue(field.required, currentObj)){ return; }
if(field.hidden && joe.propAsFuncOrValue(field.hidden, currentObj)){ return; }
if(field.condition && !joe.propAsFuncOrValue(field.condition, currentObj)){ return; }
totalCount++;
if (self.isFieldComplete(field, currentObj)) {
completed++;
}else{
missing.push(field.display || field.label || field.name);
}
});
if(!totalCount){ return; }
var percentage = Math.round((completed / totalCount) * 100);
sectionProgress[sectionId] = {
name: section.name || sectionId,
completed: completed,
total: totalCount,
percentage: percentage,
isComplete: percentage === 100,
anchor: section.anchor || sectionId,
missing: missing
};
});
// Render widget HTML
var widgetTitle = this.config.title;
if (!widgetTitle && this.schemaName) {
// Capitalize first letter of schema name
widgetTitle = (this.schemaName.charAt(0).toUpperCase() + this.schemaName.slice(1)) + ' Workflow';
}
if (!widgetTitle) {
widgetTitle = 'Workflow'; // fallback
}
var html = '<div class="joe-workflow-widget-header">' + widgetTitle + '</div>';
var hasSections = false;
sectionsToShow.forEach(function(sectionId) {
var progress = sectionProgress[sectionId];
if (!progress) return;
hasSections = true;
var statusHtml = progress.isComplete
? '<span class="joe-workflow-checkmark" title="Complete">✓</span>'
: '<span class="joe-workflow-percentage" title="' + progress.completed + ' of ' + progress.total + ' fields complete">' + progress.percentage + '%</span>';
var ji = (self.joeIndex !== null ? self.joeIndex : 0);
var safeSectionIdHtml = (sectionId || '').toString()
.replace(/&/g, '&')
.replace(/"/g, '"');
// Use double-quotes inside gotoSection(...) to avoid JS escaping issues.
var clickAction = 'onclick="try{ getJoe(' + ji + ').gotoSection("' + safeSectionIdHtml + '"); }catch(e){}" style="cursor:pointer;"';
var tip = '';
if(progress.missing && progress.missing.length){
tip = progress.missing.join(' | ')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
html += '<div class="joe-workflow-section" ' + clickAction + (tip ? (' title="' + tip + '"') : '') + '>';
html += '<div class="joe-workflow-section-label">' + progress.name + '</div>';
html += '<div class="joe-workflow-section-status">' + statusHtml + '</div>';
html += '</div>';
});
if (!hasSections) {
html += '<div class="joe-workflow-empty">No sections to track</div>';
}
this.innerHTML = html;
this._rendering = false;
}
isFieldComplete(field, obj) {
var value = obj[field.name];
var fieldType = (field.type || '').toLowerCase();
var mustBeTrue = this.config.mustBeTrue && Array.isArray(this.config.mustBeTrue)
&& this.config.mustBeTrue.indexOf(field.name) !== -1;
if (value === undefined || value === null) {
return false;
}
switch (fieldType) {
case 'text':
case 'rendering':
case 'code':
case 'wysiwyg':
case 'url':
return typeof value === 'string' && value.trim().length > 0;
case 'objectreference':
return Array.isArray(value) ? value.length > 0 : !!value;
case 'objectlist':
return Array.isArray(value) && value.length > 0;
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
if (mustBeTrue) {
return value === true;
}
return typeof value === 'boolean';
case 'date':
case 'date-time':
return !!value;
case 'select':
return value !== '' && value !== null && value !== undefined;
case 'uploader':
return Array.isArray(value) && value.length > 0;
default:
if (Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === 'object') {
return Object.keys(value).length > 0;
}
return !!value;
}
}
}
window.customElements.define('joe-workflow-widget', JoeWorkflowWidget);