Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist tied to a calendar's view and only inside its footprint. Tasks are now a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that can be placed and sized independently, same as photos/calendar/ whiteboard -- no separate "enabled" flag either, since being on the grid at all is the on/off switch, matching every other widget type. Migration 17 creates task_widget_configs, extracts any existing calendar widget's configured task source into a new sibling tasks widget (auto-placed in open grid space, source dropped+logged if truly none is left), then drops calendar_widget_configs' now-dead tasks_* columns in the same migration -- this project's usual same-migration- drop convention. Also handles the rarer case of a database jumping straight from before the widget system existed to after this split in one boot, via the legacy Frame.calendar_tasks_* columns. Verified live in the browser at desktop and mobile widths: adding a Tasks widget, its own dialog (task-list source picker + preview), and confirming the calendar widget's dialog no longer mentions tasks at all. Full test suite (180 tests, including new coverage for the widget render/actions, the migration's data-extraction path, and the permission-boundary shape for tasks-source) passes.
This commit is contained in:
@@ -60,7 +60,7 @@
|
||||
|
||||
// Shared display names for widget_type, everywhere one shows up in the
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' };
|
||||
const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks' };
|
||||
|
||||
function showStatus(ok, message) {
|
||||
// While a <dialog> is open, its own .dialog-result container gets the
|
||||
|
||||
@@ -280,12 +280,18 @@ window.addEventListener('resize', () => {
|
||||
// a frame can now have several widgets of the same type, so "the
|
||||
// Calendar tab" stopped meaning anything unambiguous.
|
||||
|
||||
// widget_dialog_{photos,calendar,whiteboard}.js each define an
|
||||
// widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an
|
||||
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
|
||||
// frame_layout.html, since which one runs depends on which widget's gear
|
||||
// icon was clicked).
|
||||
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
|
||||
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
|
||||
const DIALOG_INIT = {
|
||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||
tasks: initTasksDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||
tasks: closeTasksDialog,
|
||||
};
|
||||
|
||||
let openDialogWidgetType = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Calendar widget dialog: view/week-start settings, per-user opt-in,
|
||||
// weather, tasks, and the rendered preview. Not a page-load script --
|
||||
// weather, and the rendered preview. Not a page-load script --
|
||||
// frame_layout.js fetches this widget's dialog HTML fragment, injects
|
||||
// it into the shared <dialog>, points window.FRAME_API at this specific
|
||||
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
|
||||
@@ -64,48 +64,6 @@ async function removeWeatherCity(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrites #tasks-current-source in place instead of telling the user
|
||||
// to reload -- the API always assigns a successful "set" to the caller
|
||||
// (see api_widget_tasks_source), so after either action we already know
|
||||
// exactly what the new state is without asking the server again.
|
||||
function renderTasksCurrentSource(label) {
|
||||
const container = document.getElementById('tasks-current-source');
|
||||
container.innerHTML = '';
|
||||
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
p.style.marginTop = '10px';
|
||||
p.append('Currently using your ');
|
||||
const labelEl = document.createElement('strong');
|
||||
labelEl.textContent = label;
|
||||
p.append(labelEl, ' list. ');
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.type = 'button';
|
||||
clearBtn.className = 'btn-inline secondary';
|
||||
clearBtn.id = 'tasks-source-clear';
|
||||
clearBtn.textContent = 'Clear';
|
||||
clearBtn.addEventListener('click', clearTasksSource);
|
||||
p.append(clearBtn);
|
||||
container.append(p);
|
||||
}
|
||||
|
||||
async function clearTasksSource() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ calendar_key: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Task list cleared.');
|
||||
renderTasksCurrentSource(null);
|
||||
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
|
||||
loadCalendarPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadCalendarPreview() {
|
||||
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
|
||||
}
|
||||
@@ -237,50 +195,6 @@ function initCalendarDialog() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
|
||||
const el = e.target;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadCalendarPreview();
|
||||
} catch (e) {
|
||||
el.checked = !el.checked;
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Choosing one of your own CalDAV task lists as this widget's source --
|
||||
// owner-only (see api_widget_tasks_source), so these radios only ever
|
||||
// render for the viewer's own calendars anyway.
|
||||
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ calendar_key: el.dataset.key }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Task list saved.');
|
||||
const labelEl = el.closest('li').querySelector('label');
|
||||
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
|
||||
loadCalendarPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tasksSourceClear = document.getElementById('tasks-source-clear');
|
||||
if (tasksSourceClear) {
|
||||
tasksSourceClear.addEventListener('click', clearTasksSource);
|
||||
}
|
||||
|
||||
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||
loadCalendarPreview();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Tasks widget dialog: pick one of the viewer's own CalDAV task lists as
|
||||
// this widget's source, plus the rendered preview. Not a page-load
|
||||
// script -- frame_layout.js fetches this widget's dialog HTML fragment,
|
||||
// injects it into the shared <dialog>, points window.FRAME_API at this
|
||||
// specific widget (/api/frames/{id}/widgets/{widget_id}), then calls
|
||||
// initTasksDialog().
|
||||
|
||||
// Rewrites #tasks-current-source in place instead of telling the user to
|
||||
// reload -- the API always assigns a successful "set" to the caller
|
||||
// (see api_widget_tasks_source), so after either action we already know
|
||||
// exactly what the new state is without asking the server again.
|
||||
function renderTasksCurrentSource(label) {
|
||||
const container = document.getElementById('tasks-current-source');
|
||||
container.innerHTML = '';
|
||||
if (!label) {
|
||||
container.innerHTML = '<p class="sub" style="margin-top: 10px;">No task list configured yet.</p>';
|
||||
return;
|
||||
}
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
p.style.marginTop = '10px';
|
||||
p.append('Currently using your ');
|
||||
const labelEl = document.createElement('strong');
|
||||
labelEl.textContent = label;
|
||||
p.append(labelEl, ' list. ');
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.type = 'button';
|
||||
clearBtn.className = 'btn-inline secondary';
|
||||
clearBtn.id = 'tasks-source-clear';
|
||||
clearBtn.textContent = 'Clear';
|
||||
clearBtn.addEventListener('click', clearTasksSource);
|
||||
p.append(clearBtn);
|
||||
container.append(p);
|
||||
}
|
||||
|
||||
async function clearTasksSource() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ calendar_key: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Task list cleared.');
|
||||
renderTasksCurrentSource(null);
|
||||
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
|
||||
loadTasksPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadTasksPreview() {
|
||||
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
function initTasksDialog() {
|
||||
// Choosing one of your own CalDAV task lists as this widget's source --
|
||||
// owner-only (see api_widget_tasks_source), so these radios only ever
|
||||
// render for the viewer's own calendars anyway.
|
||||
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ calendar_key: el.dataset.key }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Task list saved.');
|
||||
const labelEl = el.closest('li').querySelector('label');
|
||||
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
|
||||
loadTasksPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tasksSourceClear = document.getElementById('tasks-source-clear');
|
||||
if (tasksSourceClear) {
|
||||
tasksSourceClear.addEventListener('click', clearTasksSource);
|
||||
}
|
||||
|
||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||
loadTasksPreview();
|
||||
}
|
||||
|
||||
function closeTasksDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
Reference in New Issue
Block a user