Implement schedule WYSIWYG and UTC dates

This commit is contained in:
2026-08-02 14:04:41 +01:00
parent a9d1d45d78
commit 2b9cabdab2
31 changed files with 1585 additions and 52 deletions
+2
View File
@@ -21,6 +21,8 @@ module.exports = {
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
renderScheduleGroupsPage: require(routePath('data-sources', 'schedules', 'list')),
renderScheduleGroupFormPage: require(routePath('data-sources', 'schedules', 'form')),
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
+9
View File
@@ -725,6 +725,10 @@ td[data-label="Slides"] {
position: relative;
}
.slide-editor-sidebar {
align-self: start;
}
.slide-form-stack {
display: grid;
gap: 1rem;
@@ -1734,6 +1738,11 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
position: static;
}
.slide-editor-sidebar {
position: static;
top: auto;
}
.slide-editor-shell {
grid-template-columns: minmax(0, 1fr);
}
@@ -0,0 +1,35 @@
// Minimal row editor for schedule groups.
(function () {
var form = document.getElementById('schedule-group-form');
var body = document.querySelector('[data-schedule-entries-body]');
var addButton = document.querySelector('[data-add-schedule-entry]');
var template = document.getElementById('schedule-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-schedule-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-schedule-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove);
addButton.addEventListener('click', addRow);
}());
+338
View File
@@ -0,0 +1,338 @@
// Schedule region helpers for editor previews and defaults.
(function () {
var registry = window.pulseRegionTypes;
var utils = window.pulseRegionUtils || {};
var placeholderChips = window.placeholderChips || {};
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 28,
font_color: '#000000'
};
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function sanitizeRichText(html) {
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
}
function getDefaultStyle() {
return {
font_family: DEFAULT_STYLE.font_family,
font_size: DEFAULT_STYLE.font_size,
font_color: DEFAULT_STYLE.font_color
};
}
function getScheduleGroups() {
return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
function getEntries(groupId, groups) {
var group = getGroupById(groupId, groups);
return group && Array.isArray(group.entries) ? group.entries : [];
}
function toDate(value) {
if (!value) {
return null;
}
var date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isUpcoming(entry, now) {
var start = toDate(entry && entry.start_datetime);
return Boolean(start && now < start);
}
function isLive(entry, now) {
var start = toDate(entry && entry.start_datetime);
var end = toDate(entry && entry.end_datetime);
return Boolean(start && end && now >= start && now < end);
}
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
var now = new Date();
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
var leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.start_datetime);
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
});
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
entries = entries.filter(function (entry) {
if (mode === 'current') {
return isLive(entry, now);
}
if (mode === 'both') {
return isUpcoming(entry, now) || isLive(entry, now);
}
return isUpcoming(entry, now);
});
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
function renderTemplate(template, context) {
var source = String(template || '');
if (!source) {
return '';
}
if (placeholderUtils && typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(context, expression)));
});
}
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
var value = context;
String(expression || '').trim().split('.').forEach(function (segment) {
if (value === undefined || value === null) {
value = '';
return;
}
value = value[segment];
});
return escapeHtml(value);
});
}
function getCurrentConfig(region, existingContent) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
};
}
function getSchedulePlaceholderTokens() {
return ['title', 'short_description', 'start', 'end'];
}
function renderSchedulePlaceholderChips(group, entries) {
var tokens = getSchedulePlaceholderTokens(group, entries);
if (placeholderChips && typeof placeholderChips.renderChips === 'function') {
return placeholderChips.renderChips(tokens);
}
return tokens.map(function (token) {
return '<span class="chip">{{' + escapeHtml(token) + '}}</span>';
}).join('');
}
function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
var current = existingContent[regionId] || {};
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null;
var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id));
var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, scheduleGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups);
if (!entries.length) {
entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
}
return {
group: group,
entries: entries,
displayMode: displayMode,
maxItems: maxItems
};
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
var currentGroup = getGroupById(currentGroupId, scheduleGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups);
if (!currentEntries.length) {
currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
}
var groupOptions = scheduleGroups.map(function (group) {
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
return '<option value="' + escapeHtml(group.id) + '"' + selected + '>' + escapeHtml(group.name || ('Group ' + group.id)) + '</option>';
}).join('');
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions"><span class="chip">Schedule</span></div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="editor-holder" data-region-id="' + region.id + '">' +
'<textarea class="editor-source" rows="10">' + escapeHtml(currentValue) + '</textarea>' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
'</div>' +
'<div class="row g-3">' +
'<div class="col-12 col-lg-6">' +
'<label class="form-label" for="region_schedule_group_id_' + region.id + '">Schedule group</label>' +
'<select id="region_schedule_group_id_' + region.id + '" name="region_schedule_group_id_' + region.id + '" class="form-select">' +
'<option value="">Select a group</option>' +
groupOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_schedule_display_mode_' + region.id + '" name="region_schedule_display_mode_' + region.id + '" class="form-select">' +
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_max_items_' + region.id + '">Max items</label>' +
'<input id="region_schedule_max_items_' + region.id + '" type="number" min="1" step="1" name="region_schedule_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'</div>' +
'<div class="col-12 col-lg-9 text-body-secondary small">Use the selected group and display mode to choose which schedule entry fields are available.</div>' +
'</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-2">Format start and end values with expressions like <code>{{start.format("MMM D, YYYY h:mm A")}}</code> and <code>{{end.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>';
}
function getTextStyle(region, current) {
var defaultStyle = getDefaultStyle();
return {
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
};
}
function updateSchedulePlaceholderPanel(card) {
if (!card) {
return;
}
var regionId = card.getAttribute('data-region-id');
var panel = card.querySelector('[data-schedule-placeholder-chips] .d-flex.flex-wrap.gap-2');
if (!regionId || !panel) {
return;
}
var groups = getScheduleGroups();
var current = getCurrentSelection(regionId, card, {}, groups);
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
window.__scheduleRegionPlaceholderRefreshInstalled = true;
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
return;
}
var card = target.closest('[data-region-id]');
if (card) {
updateSchedulePlaceholderPanel(card);
}
}, true);
}
function buildEditorCardContext(context) {
return {
region: context.region,
current: context.current || {},
scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
};
}
function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
var current = getCurrentConfig(region, existingContent || {});
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null;
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value)));
return {
value: value,
style: getTextStyle(region, current),
schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id,
display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode,
max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
};
}
function renderPreview(region, regionContent, context) {
var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
if (!value) {
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
}
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
entries: entries,
index: index + 1
}))) + '</div>';
}).join('') + '</div>';
}
registry.register('schedule', {
label: 'Schedule',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 520, height: 280 };
},
getCurrentConfig: getCurrentConfig,
renderPreview: renderPreview,
renderEditorCard: renderEditorCard,
buildEditorCardContext: buildEditorCardContext,
buildPreviewRenderContext: buildPreviewRenderContext
});
}());
+81 -6
View File
@@ -2,7 +2,12 @@
(function () {
var root = window;
var transformPattern = /^(upper|lower|title)\(\)$/i;
var transformPattern = /^([a-z_][a-z0-9_]*)\((.*)\)$/i;
var monthNamesShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var monthNamesLong = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
var dayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var dayNamesLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function resolvePath(value, path) {
var current = value;
@@ -29,11 +34,15 @@
while (segments.length) {
var candidate = String(segments[segments.length - 1] || '').trim();
if (!transformPattern.test(candidate)) {
var match = candidate.match(transformPattern);
if (!match) {
break;
}
transforms.unshift(candidate.replace(/\(\)$/g, '').toLowerCase());
transforms.unshift({
name: String(match[1] || '').trim().toLowerCase(),
args: match[2] ? splitTransformArgs(match[2]) : []
});
segments.pop();
}
@@ -43,23 +52,89 @@
};
}
function splitTransformArgs(value) {
var source = String(value || '').trim();
if (!source) {
return [];
}
if ((source[0] === '"' && source[source.length - 1] === '"') || (source[0] === '\'' && source[source.length - 1] === '\'')) {
return [source.slice(1, -1)];
}
return source.split(',').map(function (item) {
return String(item || '').trim();
}).filter(Boolean);
}
function padNumber(value, size) {
var text = String(Math.abs(Number(value || 0)));
while (text.length < size) {
text = '0' + text;
}
return text;
}
function formatDateValue(value, pattern) {
var date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
var hours24 = date.getHours();
var hours12 = hours24 % 12 || 12;
var tokenMap = {
YYYY: String(date.getFullYear()),
YY: String(date.getFullYear()).slice(-2),
MMMM: monthNamesLong[date.getMonth()],
MMM: monthNamesShort[date.getMonth()],
MM: padNumber(date.getMonth() + 1, 2),
M: String(date.getMonth() + 1),
DD: padNumber(date.getDate(), 2),
D: String(date.getDate()),
dddd: dayNamesLong[date.getDay()],
ddd: dayNamesShort[date.getDay()],
HH: padNumber(hours24, 2),
H: String(hours24),
hh: padNumber(hours12, 2),
h: String(hours12),
mm: padNumber(date.getMinutes(), 2),
m: String(date.getMinutes()),
ss: padNumber(date.getSeconds(), 2),
s: String(date.getSeconds()),
A: hours24 >= 12 ? 'PM' : 'AM',
a: hours24 >= 12 ? 'pm' : 'am'
};
return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) {
return literal || tokenMap[match] || match;
});
}
function applyTransform(value, transform) {
var text = String(value === undefined || value === null ? '' : value);
var name = String(transform && transform.name || '').trim().toLowerCase();
var args = Array.isArray(transform && transform.args) ? transform.args : [];
if (transform === 'lower') {
if (name === 'lower') {
return text.toLowerCase();
}
if (transform === 'upper') {
if (name === 'upper') {
return text.toUpperCase();
}
if (transform === 'title') {
if (name === 'title') {
return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
return String(letter || '').toUpperCase();
});
}
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') {
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'));
}
return text;
}
+22 -3
View File
@@ -23,6 +23,22 @@ export function createSlideFormEditorController(options) {
return String(value === undefined || value === null ? '' : value).trim();
}
function isEmptyRichTextValue(value) {
var raw = String(value === undefined || value === null ? '' : value).trim();
if (!raw) {
return true;
}
var stripped = raw
.replace(/<\s*br\s*\/?>/gi, '')
.replace(/<p[^>]*>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !stripped;
}
function normalizeFontSizeValue(value) {
var raw = String(value || '').trim().toLowerCase();
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
@@ -156,6 +172,7 @@ export function createSlideFormEditorController(options) {
editor.save();
}
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) {
hidden.value = content;
}
@@ -170,6 +187,7 @@ export function createSlideFormEditorController(options) {
editor.on('init', function () {
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) {
hidden.value = content;
}
@@ -215,20 +233,21 @@ export function createSlideFormEditorController(options) {
promotion: false,
statusbar: true,
resize: true,
plugins: 'lists link code advlist fullscreen',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist | fullscreen',
plugins: 'lists link code advlist fullscreen table',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
toolbar_mode: 'sliding',
license_key: 'gpl',
skin: themeAssets.skinName,
skin_url: themeAssets.skinUrl,
content_css: getContentCss(),
body_class: themeAssets.bodyClass,
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
font_family_formats: getFontFamilyFormats(),
font_size_input_default_unit: 'px',
forced_root_block: 'p',
force_br_newlines: false,
newline_behavior: 'default',
placeholder: String(source.getAttribute('placeholder') || '').trim(),
setup: function (editor) {
editorInstances.set(regionId, editor);
attachEditorEvents(regionId, editor);
+32 -8
View File
@@ -8,6 +8,7 @@ export function createSlideFormRegionHelpers(options) {
var existingContent = settings.existingContent || {};
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var scheduleGroups = Array.isArray(settings.scheduleGroups) ? settings.scheduleGroups : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var placeholderUtils = window.placeholderUtils || {};
var placeholderChips = window.placeholderChips || {};
@@ -459,6 +460,15 @@ export function createSlideFormRegionHelpers(options) {
};
}
function getCurrentScheduleConfig(region) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
max_items: Math.max(1, Number(current.max_items || 5))
};
}
function getCurrentRegionVideoDuration(region) {
var current = existingContent[region.region_key] || {};
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
@@ -470,13 +480,13 @@ export function createSlideFormRegionHelpers(options) {
if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
config: region.region_type === 'api' ? getCurrentApiConfig(region) : getCurrentRssConfig(region),
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region),
style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -492,8 +502,9 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path)
}, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
scheduleGroups: scheduleGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -510,7 +521,7 @@ export function createSlideFormRegionHelpers(options) {
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: currentContent.disable_audio,
config: region.region_type === 'api' ? apiConfig : rssConfig,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig,
style: textStyle,
fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -526,7 +537,8 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath)
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
scheduleGroups: scheduleGroups
};
}
@@ -542,7 +554,7 @@ export function createSlideFormRegionHelpers(options) {
function getPreviewRegionContent(card, region) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var current = existingContent[region.region_key];
@@ -592,6 +604,14 @@ export function createSlideFormRegionHelpers(options) {
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
} else if (region.region_type === 'schedule') {
var scheduleGroupInput = card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]');
var scheduleDisplayModeInput = card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]');
var scheduleMaxItemsInput = card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]');
var currentSchedule = getCurrentScheduleConfig(region);
content.schedule_group_id = scheduleGroupInput ? scheduleGroupInput.value : currentSchedule.schedule_group_id;
content.display_mode = scheduleDisplayModeInput ? scheduleDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = scheduleMaxItemsInput ? scheduleMaxItemsInput.value : currentSchedule.max_items;
} else {
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
}
@@ -602,7 +622,7 @@ export function createSlideFormRegionHelpers(options) {
function buildPreviewRenderContext(region, card) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -619,6 +639,10 @@ export function createSlideFormRegionHelpers(options) {
item_number: previewContent.item_number,
source_id: previewContent.source_id,
items_path: previewContent.items_path,
schedule_group_id: previewContent.schedule_group_id,
display_mode: previewContent.display_mode,
max_items: previewContent.max_items,
scheduleGroups: scheduleGroups,
font_size: textStyle.font_size,
font_color: textStyle.font_color,
font_family: textStyle.font_family
+6 -1
View File
@@ -21,6 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var scheduleGroups = Array.isArray(slideEditorData.scheduleGroups) ? slideEditorData.scheduleGroups : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
@@ -49,7 +50,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var currentPreviewCanvasWidth = 0;
var currentPreviewCanvasHeight = 0;
var sidebarSyncFrame = 0;
var sidebarTopOffset = 16;
var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
var regionTypes = window.pulseRegionTypes || {};
@@ -119,6 +120,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
existingContent: existingContent,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -487,6 +489,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return;
}
var stickyHeader = document.querySelector('.app-header');
var stickyHeaderHeight = stickyHeader ? Math.max(0, Math.round(stickyHeader.getBoundingClientRect().height || stickyHeader.offsetHeight || 0)) : 0;
var sidebarTopOffset = sidebarBaseOffset + stickyHeaderHeight + 12;
var shellRect = slideEditorShell.getBoundingClientRect();
var sidebarHeight = slideEditorSidebar.offsetHeight;
var shellTop = window.scrollY + shellRect.top;
+4 -2
View File
@@ -274,6 +274,7 @@ module.exports = function registerContentRoutes(app, deps) {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -287,7 +288,7 @@ module.exports = function registerContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -304,6 +305,7 @@ module.exports = function registerContentRoutes(app, deps) {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -317,7 +319,7 @@ module.exports = function registerContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+291 -2
View File
@@ -13,6 +13,7 @@ module.exports = function registerDataSourceRoutes(app, deps) {
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
@@ -73,6 +74,7 @@ module.exports = function registerDataSourceRoutes(app, deps) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
const rssFeedIds = new Set();
const scheduleGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
@@ -104,6 +106,13 @@ module.exports = function registerDataSourceRoutes(app, deps) {
}
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const scheduleGroupId = Number(value.schedule_group_id);
if (Number.isFinite(scheduleGroupId)) {
scheduleGroupIds.add(scheduleGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
@@ -112,10 +121,28 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return {
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds
rssFeedIds: rssFeedIds,
scheduleGroupIds: scheduleGroupIds
};
}
function readScheduleArrayField(body, key) {
if (!body || !Object.prototype.hasOwnProperty.call(body, key)) {
return [];
}
const value = body[key];
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
@@ -150,7 +177,10 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read'])) {
return res.redirect('/data-sources/schedules');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
}
@@ -560,4 +590,263 @@ module.exports = function registerDataSourceRoutes(app, deps) {
next(error);
}
});
app.get('/data-sources/schedules', requirePermission('schedules.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id))
});
});
res.send(pages.renderScheduleGroupsPage({
scheduleGroups: scheduleGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) {
res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildScheduleGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', {
closeUrl: '/data-sources/schedules',
newUrl: '/data-sources/schedules/new',
message: 'Schedule group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id);
const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function'
? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id)
: [];
res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, {
shortDescription: scheduleGroup.short_description || '',
inUse: inUse
}), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const payload = common.buildScheduleGroupPayload(req, scheduleGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) {
return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, scheduleGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
scheduleGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
const message = 'Schedule group updated.';
res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group');
if (blockMessage) {
return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,54 @@
// Schedule group add/edit renderer and defaults.
const { renderView } = require('../../../view');
function buildDefaultScheduleGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {});
const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length
? scheduleEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return renderView('data-sources/schedules/form', {
title: isEdit ? 'Edit schedule group' : 'Add schedule group',
active: 'schedules',
message: message,
currentUser: currentUser || null,
isEdit: isEdit,
scheduleGroup: viewScheduleGroup,
scheduleEntries: viewScheduleEntries,
inUse: Boolean(viewScheduleGroup.inUse),
assetVersion: Date.now().toString(36)
});
};
@@ -0,0 +1,32 @@
// Schedule group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) {
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate),
nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/schedules/list', {
title: 'Schedules',
active: 'schedules',
message: message,
currentUser: currentUser || null,
scheduleGroups: scheduleGroups,
pagination: data.pagination || null
});
};
+2
View File
@@ -15,6 +15,8 @@ module.exports = {
renderApiSourcesPage: require('./data-sources/api-sources/list'),
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
renderScheduleGroupsPage: require('./data-sources/schedules/list'),
renderScheduleGroupFormPage: require('./data-sources/schedules/form'),
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
+1
View File
@@ -168,6 +168,7 @@ function registerSettingsAndContentRoutes(app, deps) {
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
+2
View File
@@ -8,6 +8,7 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
const templateRegions = data.templateRegions || [];
const rssFeeds = data.rssFeeds || [];
const apiSources = data.apiSources || [];
const scheduleGroups = data.scheduleGroups || [];
const fontLibrary = data.fontLibrary || null;
const templates = (data.templates || []).map((template) => ({
...template,
@@ -30,6 +31,7 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
@@ -0,0 +1,111 @@
<div class="page-header">
<div>
<h2>{{#if isEdit}}Edit schedule group{{else}}Add schedule group{{/if}}</h2>
<p>Define a group of events with their start and end times.</p>
</div>
</div>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Schedule group details</h3>
</div>
<form id="schedule-group-form" method="post" action="{{#if isEdit}}/data-sources/schedules/{{scheduleGroup.id}}{{else}}/data-sources/schedules{{/if}}" data-async-save data-async-save-close-url="/data-sources/schedules" data-async-save-new-url="/data-sources/schedules/new">
<div class="card-body d-grid gap-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<label for="schedule-group-name" class="form-label">Name</label>
<input id="schedule-group-name" name="name" class="form-control" value="{{scheduleGroup.name}}" required />
</div>
<div class="col-12 col-lg-8">
<label for="schedule-group-short-description" class="form-label">Short description</label>
<input id="schedule-group-short-description" name="short_description" class="form-control" value="{{scheduleGroup.shortDescription}}" placeholder="Morning events" />
</div>
</div>
<div>
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<h4 class="schedule-section-heading mb-0">Entries</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-schedule-entry>Add entry</button>
</div>
<div class="table-responsive">
<table class="table table-bordered align-middle schedule-entries-table mb-0">
<thead>
<tr>
<th style="width: 18rem;">Title</th>
<th>Description</th>
<th style="width: 12rem;">Start</th>
<th style="width: 12rem;">End</th>
<th style="width: 6rem;">Actions</th>
</tr>
</thead>
<tbody data-schedule-entries-body>
{{#each scheduleEntries}}
<tr data-schedule-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="{{id}}" />
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<p class="form-text mb-0">Add one or more rows. A schedule region can render upcoming or current entries from this group.</p>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Schedule group actions">
{{{saveActionButtons formId="schedule-group-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/schedules" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if isEdit}}
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-schedule-group-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-schedule-group-form">Delete</button>
{{/if}}
{{else}}
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
{{/if}}
</div>
</div>
</form>
</div>
<template id="schedule-entry-row-template">
<tr data-schedule-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="" />
<input type="text" name="entry_title[]" class="form-control" value="" required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="" placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
</td>
</tr>
</template>
{{#if isEdit}}
<form id="delete-schedule-group-form" method="post" action="/data-sources/schedules/{{scheduleGroup.id}}/delete" data-confirm-message="Delete this schedule group?"></form>
{{/if}}
<script type="module" src="/assets/js/data-sources/schedule-group-form.js?v={{assetVersion}}"></script>
@@ -0,0 +1,75 @@
<div class="page-header">
<div>
<h2>Schedules</h2>
<p>Store grouped events with start and end times so a schedule region can show what is coming up next.</p>
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Saved groups</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search schedules" aria-label="Search schedules" data-table-search />
</div>
{{#if (hasPermission currentUser 'schedules.create')}}
<a class="btn btn-primary btn-sm" href="/data-sources/schedules/new">Add schedule group</a>
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="entries">Entries</th>
<th data-table-sort-key="next_start">Next start</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if scheduleGroups.length}}
{{#each scheduleGroups}}
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Description" class="text-break">{{short_description}}</td>
<td data-label="Entries">{{entry_count}}</td>
<td data-label="Next start">
{{#if nextStartValue}}
<time data-local-datetime datetime="{{nextStartValue}}">{{nextStartLabel}}</time>
{{else}}
{{nextStartLabel}}
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'schedules.update' 'schedules.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'schedules.update')}}
<a class="btn btn-sm btn-primary" href="/data-sources/schedules/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'schedules.delete')}}
<form class="inline-form" method="post" action="/data-sources/schedules/{{id}}/delete" data-confirm-message="Delete this schedule group?">
{{#if inUse}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No schedule groups yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/data-sources/schedules" alwaysShow=true}}
</div>
+9 -1
View File
@@ -215,7 +215,7 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'api-sources.read')}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
<li class="nav-header">DATA SOURCES</li>
{{#if (hasPermission currentUser 'rss-feeds.read')}}
<li class="nav-item">
@@ -225,6 +225,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'schedules.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'schedules')}}active{{/if}}" href="/data-sources/schedules">
<i class="nav-icon bi bi-calendar-event"></i>
<p>Schedules</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'api-sources.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">