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

  1. 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.
  2. 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).
  3. 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).
  4. 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.
  5. 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

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.