Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ alongside the solver. When you scaffold a new SolverForge project with
| `SF.showError(title, detail)` | `void` | Danger toast shorthand |
| `SF.showTab(tabId)` | `void` | Activate a tab panel by ID |

### Unsafe HTML APIs (opt-in)

Default content is always text-rendered. Use these fields only with trusted HTML:

| Factory | Unsafe HTML field |
|---------|-------------------|
| `SF.el(tag, attrs, ...)` | `unsafeHtml` |
| `SF.createModal(config)` | `unsafeBody` |
| `SF.createTabs(config)` | `tabs[].content.unsafeHtml` |
| `SF.createTable(config)` | `cells[].unsafeHtml` |
| `SF.gantt.create(config)` | `unsafePopupHtml`, `columns[].render(task).unsafeHtml` |

### Timeline Rail

| Factory | Returns | Description |
Expand Down Expand Up @@ -220,7 +232,9 @@ var gantt = SF.gantt.create({
{ key: 'start', label: 'Start' },
{ key: 'end', label: 'End' },
{ key: 'priority', label: 'P', render: function (t) {
return '<span class="sf-priority-badge priority-' + t.priority + '">P' + t.priority + '</span>';
return {
unsafeHtml: '<span class="sf-priority-badge priority-' + t.priority + '">P' + t.priority + '</span>',
};
}},
],
onTaskClick: function (task) { console.log('clicked', task.id); },
Expand Down
3 changes: 2 additions & 1 deletion js-src/00-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const SF = (function () {
}
else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else if (key === 'dataset') Object.assign(el.dataset, attrs[key]);
else if (key === 'html') el.innerHTML = attrs[key];
else if (key === 'html') el.textContent = attrs[key];
else if (key === 'unsafeHtml') el.innerHTML = attrs[key];
else el.setAttribute(key, attrs[key]);
});
}
Expand Down
32 changes: 16 additions & 16 deletions js-src/06-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,22 @@
sf.createModal = function (config) {
var overlay = sf.el('div', { className: 'sf-modal-overlay' });
var dialog = sf.el('div', { className: 'sf-modal' });
var body = sf.el('div', { className: 'sf-modal-body' });

// Header
var header = sf.el('div', { className: 'sf-modal-header' });
header.appendChild(sf.el('div', { className: 'sf-modal-title' }, config.title || ''));

var closeBtn = sf.el('button', {
className: 'sf-modal-close',
html: '&times;',
onClick: function () { api.close(); },
});
}, '×');
header.appendChild(closeBtn);

dialog.appendChild(header);

// Body
var body = sf.el('div', { className: 'sf-modal-body' });
if (config.body) {
if (typeof config.body === 'string') {
body.innerHTML = config.body;
} else if (config.body instanceof Node) {
body.appendChild(config.body);
}
}
setBodyContent(body, config.body || config.unsafeBody);
dialog.appendChild(body);

// Footer
Expand Down Expand Up @@ -69,12 +63,7 @@
};

api.setBody = function (content) {
body.innerHTML = '';
if (typeof content === 'string') {
body.innerHTML = content;
} else if (content instanceof Node) {
body.appendChild(content);
}
setBodyContent(body, content);
};

if (config.width) {
Expand All @@ -84,4 +73,15 @@
return api;
};

function setBodyContent(target, content) {
target.textContent = '';
if (typeof content === 'string') {
target.textContent = content;
} else if (content && content.unsafeHtml) {
target.innerHTML = content.unsafeHtml;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat unsafeBody as raw HTML in modal helper

The new modal API advertises unsafeBody as the opt-in HTML hook, but this helper only switches to innerHTML when content.unsafeHtml exists. If a consumer follows the new API and passes unsafeBody: '<strong>...</strong>', the markup is still written through textContent, so both the initial modal body and any later setBody() updates render the literal tags instead of HTML.

Useful? React with 👍 / 👎.

} else if (content instanceof Node) {
target.appendChild(content);
}
}

})(SF);
3 changes: 2 additions & 1 deletion js-src/07-tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
id: 'sf-tab-' + tab.id,
});
if (tab.content) {
if (typeof tab.content === 'string') panel.innerHTML = tab.content;
if (typeof tab.content === 'string') panel.textContent = tab.content;
else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml;
else if (tab.content instanceof Node) panel.appendChild(tab.content);
}
container.appendChild(panel);
Expand Down
4 changes: 2 additions & 2 deletions js-src/08-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
td.textContent = cell;
} else if (cell instanceof Node) {
td.appendChild(cell);
} else if (cell && cell.html) {
td.innerHTML = cell.html;
} else if (cell && cell.unsafeHtml) {
td.innerHTML = cell.unsafeHtml;
}
var col = config.columns && config.columns[colIdx];
if (col && col.align) td.style.textAlign = col.align;
Expand Down
3 changes: 1 addition & 2 deletions js-src/09-toast.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@

var closeBtn = sf.el('button', {
className: 'sf-toast-close',
html: '&times;',
onClick: function () { dismiss(); },
});
}, '×');
toast.appendChild(closeBtn);

container.appendChild(toast);
Expand Down
21 changes: 16 additions & 5 deletions js-src/14-gantt.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,27 @@
var frappeTasks = tasksToFrappe(taskList);

if (frappeTasks.length === 0) {
chartContainer.innerHTML = '<div style="padding:24px;color:var(--sf-gray-400);font-family:var(--sf-font-mono);font-size:13px;">No scheduled tasks to display.</div>';
chartContainer.textContent = '';
chartContainer.appendChild(sf.el('div', {
className: 'sf-gantt-empty-state',
style: {
padding: '24px',
color: 'var(--sf-gray-400)',
fontFamily: 'var(--sf-font-mono)',
fontSize: '13px',
},
}, 'No scheduled tasks to display.'));
ganttChart = null;
return;
}

chartContainer.innerHTML = '<svg id="' + svgId + '"></svg>';
chartContainer.textContent = '';
chartContainer.appendChild(sf.el('svg', { id: svgId }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Create the Gantt root with an SVG namespace

Replacing the parser-created '<svg ...>' with sf.el('svg', ...) changes the chart root to document.createElement('svg'). Frappe Gantt's vendored setup_wrapper() reuses any existing child svg instead of creating its own root (static/sf/vendor/frappe-gantt/frappe-gantt.min.js), so on browsers that require createElementNS for SVG nodes the library will append rect/g children under a non-SVG element and the chart can render blank.

Useful? React with 👍 / 👎.


ganttChart = new Gantt('#' + svgId, frappeTasks, {
view_mode: viewSelect.value || 'Quarter Day',
date_format: 'YYYY-MM-DD HH:mm',
custom_popup_html: config.popupHtml || defaultPopup,
custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup,
on_click: function (task) {
ctrl.highlightTask(task.id);
if (config.onTaskClick) config.onTaskClick(task);
Expand All @@ -186,7 +196,7 @@
}

function renderGrid(taskList) {
grid.innerHTML = '';
while (grid.firstChild) grid.removeChild(grid.firstChild);
var table = sf.el('table', { className: 'sf-gantt-table' });

// Header
Expand Down Expand Up @@ -221,7 +231,8 @@
td.textContent = task.name || task.label || task.id;
} else if (col.render) {
var content = col.render(task);
if (typeof content === 'string') td.innerHTML = content;
if (typeof content === 'string') td.textContent = content;
else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml;
else if (content instanceof Node) td.appendChild(content);
} else {
td.textContent = task[col.key] || '';
Expand Down
66 changes: 39 additions & 27 deletions static/sf/sf.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const SF = (function () {
}
else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else if (key === 'dataset') Object.assign(el.dataset, attrs[key]);
else if (key === 'html') el.innerHTML = attrs[key];
else if (key === 'html') el.textContent = attrs[key];
else if (key === 'unsafeHtml') el.innerHTML = attrs[key];
else el.setAttribute(key, attrs[key]);
});
}
Expand Down Expand Up @@ -457,28 +458,22 @@ const SF = (function () {
sf.createModal = function (config) {
var overlay = sf.el('div', { className: 'sf-modal-overlay' });
var dialog = sf.el('div', { className: 'sf-modal' });
var body = sf.el('div', { className: 'sf-modal-body' });

// Header
var header = sf.el('div', { className: 'sf-modal-header' });
header.appendChild(sf.el('div', { className: 'sf-modal-title' }, config.title || ''));

var closeBtn = sf.el('button', {
className: 'sf-modal-close',
html: '&times;',
onClick: function () { api.close(); },
});
}, '×');
header.appendChild(closeBtn);

dialog.appendChild(header);

// Body
var body = sf.el('div', { className: 'sf-modal-body' });
if (config.body) {
if (typeof config.body === 'string') {
body.innerHTML = config.body;
} else if (config.body instanceof Node) {
body.appendChild(config.body);
}
}
setBodyContent(body, config.body || config.unsafeBody);
dialog.appendChild(body);

// Footer
Expand Down Expand Up @@ -518,12 +513,7 @@ const SF = (function () {
};

api.setBody = function (content) {
body.innerHTML = '';
if (typeof content === 'string') {
body.innerHTML = content;
} else if (content instanceof Node) {
body.appendChild(content);
}
setBodyContent(body, content);
};

if (config.width) {
Expand All @@ -533,6 +523,17 @@ const SF = (function () {
return api;
};

function setBodyContent(target, content) {
target.textContent = '';
if (typeof content === 'string') {
target.textContent = content;
} else if (content && content.unsafeHtml) {
target.innerHTML = content.unsafeHtml;
} else if (content instanceof Node) {
target.appendChild(content);
}
}

})(SF);
/* ============================================================================
SolverForge UI — Tab Switching
Expand All @@ -558,7 +559,8 @@ const SF = (function () {
id: 'sf-tab-' + tab.id,
});
if (tab.content) {
if (typeof tab.content === 'string') panel.innerHTML = tab.content;
if (typeof tab.content === 'string') panel.textContent = tab.content;
else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml;
else if (tab.content instanceof Node) panel.appendChild(tab.content);
}
container.appendChild(panel);
Expand Down Expand Up @@ -604,8 +606,8 @@ const SF = (function () {
td.textContent = cell;
} else if (cell instanceof Node) {
td.appendChild(cell);
} else if (cell && cell.html) {
td.innerHTML = cell.html;
} else if (cell && cell.unsafeHtml) {
td.innerHTML = cell.unsafeHtml;
}
var col = config.columns && config.columns[colIdx];
if (col && col.align) td.style.textAlign = col.align;
Expand Down Expand Up @@ -664,9 +666,8 @@ const SF = (function () {

var closeBtn = sf.el('button', {
className: 'sf-toast-close',
html: '&times;',
onClick: function () { dismiss(); },
});
}, '×');
toast.appendChild(closeBtn);

container.appendChild(toast);
Expand Down Expand Up @@ -1296,17 +1297,27 @@ const SF = (function () {
var frappeTasks = tasksToFrappe(taskList);

if (frappeTasks.length === 0) {
chartContainer.innerHTML = '<div style="padding:24px;color:var(--sf-gray-400);font-family:var(--sf-font-mono);font-size:13px;">No scheduled tasks to display.</div>';
chartContainer.textContent = '';
chartContainer.appendChild(sf.el('div', {
className: 'sf-gantt-empty-state',
style: {
padding: '24px',
color: 'var(--sf-gray-400)',
fontFamily: 'var(--sf-font-mono)',
fontSize: '13px',
},
}, 'No scheduled tasks to display.'));
ganttChart = null;
return;
}

chartContainer.innerHTML = '<svg id="' + svgId + '"></svg>';
chartContainer.textContent = '';
chartContainer.appendChild(sf.el('svg', { id: svgId }));

ganttChart = new Gantt('#' + svgId, frappeTasks, {
view_mode: viewSelect.value || 'Quarter Day',
date_format: 'YYYY-MM-DD HH:mm',
custom_popup_html: config.popupHtml || defaultPopup,
custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup,
on_click: function (task) {
ctrl.highlightTask(task.id);
if (config.onTaskClick) config.onTaskClick(task);
Expand All @@ -1318,7 +1329,7 @@ const SF = (function () {
}

function renderGrid(taskList) {
grid.innerHTML = '';
while (grid.firstChild) grid.removeChild(grid.firstChild);
var table = sf.el('table', { className: 'sf-gantt-table' });

// Header
Expand Down Expand Up @@ -1353,7 +1364,8 @@ const SF = (function () {
td.textContent = task.name || task.label || task.id;
} else if (col.render) {
var content = col.render(task);
if (typeof content === 'string') td.innerHTML = content;
if (typeof content === 'string') td.textContent = content;
else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml;
else if (content instanceof Node) td.appendChild(content);
} else {
td.textContent = task[col.key] || '';
Expand Down
Loading