Leantime/leantime · warning · RuntimeException
Plugin %s cannot be installed
Error message
Plugin %s cannot be installed
What it means
This browser console.warn comes from the load-more partial of the My To-Dos widget (app/Domain/Widgets/Templates/partials/myToDosLoadMore.blade.php:88). After fetching the next page of tasks (global pagination), the handler appends each task's HTML into an existing group container located by a key derived from the task (typically `document.querySelector` on a group selector like `#task-group-${groupKey}` / existingGroup). When no matching group element is found in the DOM, the task cannot be placed and the handler logs 'Group not found for key:' and drops that card on the floor. The comment in source even admits this 'shouldn't happen with global pagination' — it is a server/client group-invariant violation.
Source
Thrown at app/Command/InstallPluginCommand.php:38
{
/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->addArgument('plugin', InputArgument::REQUIRED, 'The plugin name');
}
/**
* {@inheritdoc}
*/
protected function executeCommand(): int
{
$name = $this->input->getArgument('plugin');
$plugin = $this->getPlugin($name);
if (! isset($plugin->foldername)) {
throw new RuntimeException(sprintf('Plugin %s cannot be installed', $plugin->name));
}
if (! $this->confirm(sprintf('Install plugin %s', $plugin->name))) {
return Command::SUCCESS;
}
return $this->plugins->installPlugin($plugin->foldername) ? Command::SUCCESS : Command::FAILURE;
}
}
View on GitHub (pinned to 9a9f49f100)
Solutions
- Reproduce deterministically: filter your to-dos so page 1 contains only groups A/B but page 2 contains a task of group C — if the warn fires for C, fix the server side to always render an (empty) group container for every possible group, or switch pagination to be per-group.
- Inspect the network response of the load-more call and compare each task's group key with the ids actually present in the DOM (`document.querySelectorAll('[id^=task-group-]')`) to spot key-format mismatches (escaping, case, whitespace).
- If the mismatch is a race with HTMX refresh, abort or ignore in-flight load-more responses when the container was swapped (check a marker/nonce set on the list root before inserting).
- Implement the fallback the comment suggests: when the group is missing, create the group heading + list container (clone the template used on first render) instead of dropping the task.
- If duplicate ids exist in the DOM (querySelector then returns the first wrong group), deduplicate by scoping the search to the widget root element.
Example fix
// before — task is silently dropped when its group is absent
} else {
// Group doesn't exist yet - this shouldn't happen with global pagination,
// but if it does, we could create a new group here
console.warn('Group not found for key:', groupKey);
}
// after — create the missing group so no task is lost
} else {
const template = document.getElementById('todoGroupTemplate');
if (template) {
const group = template.content.firstElementChild.cloneNode(true);
group.dataset.groupKey = groupKey;
group.querySelector('.todo-group-title').textContent = groupLabel;
listRoot.appendChild(group);
group.insertAdjacentHTML('beforeend', taskContent);
} else {
console.warn('[Widgets] Group not found for key and no group template:', groupKey);
}
} Defensive patterns
Strategy: type-guard
Validate before calling
// Before inserting a fetched task, validate its group container exists in the current DOM
const groupEl = document.querySelector(`#task-group-${CSS.escape(groupKey)}`);
const listRoot = document.getElementById('myToDosList');
if (!listRoot) { /* widget was swapped mid-flight; discard this response instead of inserting */ }
if (!groupEl) { /* create the group before appending, or refetch the widget root */ } Type guard
function findTodoGroup(groupKey) {
if (typeof groupKey !== 'string' || groupKey === '') return null;
const el = document.getElementById('task-group-' + CSS.escape(groupKey));
return el instanceof HTMLElement ? el : null;
}
const existingGroup = findTodoGroup(groupKey);
if (existingGroup) {
existingGroup.insertAdjacentHTML('beforeend', taskContent);
} else {
console.warn('[Widgets] Group not found for key:', groupKey);
} Prevention
- Render an empty group container for every possible group on the first page so later pages can always find a home (or create groups client-side from a template when missing).
- Use CSS.escape() when building selectors from server-provided keys — unescaped special characters are the classic cause of querySelector misses.
- Version/nonce the widget root: ignore load-more responses whose nonce no longer matches the DOM (guards against HTMX-refresh races).
- Keep server-side grouping and the JS group-key derivation in one shared helper/template so the two sides cannot drift.
When it happens
Trigger: Clicking 'load more' in the My To-Dos widget when: (1) a task on the later page has a status/date/milestone group key that had no representatives on the first page, so its group heading was never rendered; (2) the group key format changed between the initial render (server-side Blade) and the load-more response (JS-built), e.g. key escaping, umlauts/emoji, or case differences so querySelector mismatches; (3) the widget DOM was re-rendered or partially updated (another HTMX event replaced the list) while a load-more fetch was in flight, detaching the groups; (4) duplicate/blank keys produce an id like `task-group-` that matches nothing.
Common situations: Large to-do lists spanning many statuses after an admin adds a new ticket status; language/date-format differences changing group labels between initial load and pagination; race between an HTMX-triggered widget refresh and the user clicking load-more; recent refactors of the widget's grouping markup that renamed the data attributes/ids used to build the selector.
Related errors
AI-assisted analysis of Leantime/leantime@9a9f49f100 (2026-08-21).
Data as JSON: /api/errors/e3a53e6f0523df0d.
Report an issue: GitHub.