OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Symfony\Component\HttpFoundation\Response;
class AdvancedBoards extends Controller
{
private ProjectService $projectService;
private IdeaService $ideaService;
/**
* Initializes dependencies.
*/
public function init(
IdeaService $ideaService,
ProjectService $projectService
): void {
$this->ideaService = $ideaService;
$this->projectService = $projectService;
session(['lastPage' => CURRENT_URL]);
session(['lastIdeaView' => 'kanban']);
}
/**
* Displays the advanced (kanban) idea boards view.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display('ideas.advancedBoards');
}
return new Response;
}
/**
* Handles idea board mutations (create, edit, search).
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function post(array $params): Response
{
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['searchCanvas'])) {
$currentCanvasId = (int) $_POST['searchCanvas'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display('ideas.advancedBoards');
}
return new Response;
}
/**
* Resolves the current canvas ID from session or request parameters.
*/
private function resolveCurrentCanvasId(array $allCanvas, array $params): int
{
if (session()->exists('currentIdeaCanvas')) {
$currentCanvasId = session('currentIdeaCanvas');
} else {
$currentCanvasId = -1;
session(['currentIdeaCanvas' => '']);
}
if (count($allCanvas) > 0 && session('currentIdeaCanvas') == '') {
$currentCanvasId = $allCanvas[0]['id'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
if (isset($params['id'])) {
$currentCanvasId = (int) $params['id'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
return $currentCanvasId;
}
/**
* Handles creating a new idea board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$currentCanvasId = $this->ideaService->createBoard(
$_POST['canvastitle'],
(int) session('currentProject'),
(int) session('userdata.id')
);
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.idea_board_created'), 'success', 'ideaboard_created');
session(['currentIdeaCanvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/ideas/advancedBoards/');
}
/**
* Handles editing an idea board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$currentCanvasId = $this->ideaService->updateBoard($currentCanvasId, $_POST['canvastitle']);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success', 'ideaboard_edited');
return Frontcontroller::redirect(BASE_URL.'/ideas/advancedBoards/');
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas): void
{
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->ideaService->getBoardItems($currentCanvasId));
$this->tpl->assign('canvasLabels', $this->ideaService->getBoardLabels());
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Symfony\Component\HttpFoundation\Response;
class BoardDialog extends Controller
{
/**
* Constant that must be redefined.
*/
protected const CANVAS_NAME = '??';
private ProjectService $projectService;
private IdeaService $ideaService;
/**
* Initializes dependencies.
*/
public function init(ProjectService $projectService, IdeaService $ideaService): void
{
$this->projectService = $projectService;
$this->ideaService = $ideaService;
}
/**
* Displays the board dialog form.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function get(array $params): Response
{
$currentCanvasId = '';
$canvasTitle = '';
if (isset($params['id'])) {
$currentCanvasId = (int) $params['id'];
$canvasTitle = $this->ideaService->getBoardTitle($currentCanvasId);
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
}
$this->tpl->assign('canvasTitle', $canvasTitle);
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasname', 'idea');
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
if (! isset($_GET['raw'])) {
return $this->tpl->displayPartial('ideas.boardDialog');
}
return new Response;
}
/**
* Handles board creation and editing.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function post(array $params): Response
{
$currentCanvasId = '';
$canvasTitle = '';
if (isset($params['id'])) {
$currentCanvasId = (int) $params['id'];
$canvasTitle = $this->ideaService->getBoardTitle($currentCanvasId);
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
}
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
$this->tpl->assign('canvasTitle', $canvasTitle);
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasname', 'idea');
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
if (! isset($_GET['raw'])) {
return $this->tpl->displayPartial('ideas.boardDialog');
}
return new Response;
}
/**
* Handles creating a new board.
*/
private function handleNewCanvas(int|string &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$currentCanvasId = $this->ideaService->createBoardFromDialog(
$_POST['canvastitle'],
(int) session('currentProject'),
(int) session('userdata.id')
);
$this->tpl->setNotification($this->language->__('notification.board_created'), 'success', static::CANVAS_NAME.'board_created');
session(['current'.strtoupper(static::CANVAS_NAME).'Canvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/ideas/boardDialog/'.$currentCanvasId);
}
/**
* Handles editing a board title.
*/
private function handleEditCanvas(int $currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$this->ideaService->updateBoard($currentCanvasId, $_POST['canvastitle']);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/ideas/boardDialog/'.$currentCanvasId);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Symfony\Component\HttpFoundation\Response;
class DelCanvas extends Controller
{
private IdeaService $ideaService;
/**
* Initializes dependencies.
*/
public function init(IdeaService $ideaService): void
{
$this->ideaService = $ideaService;
}
/**
* Displays the delete idea board confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::DELETE)]
public function get(array $params): Response
{
return $this->tpl->display('ideas.delCanvas');
}
/**
* Handles idea board deletion. The controller gate defers (entityScoped) to the service's
* deleteCanvas(), which authorizes DELETE against the board's REAL project.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
$this->ideaService->deleteCanvas($id);
$this->tpl->setNotification($this->language->__('notification.idea_board_deleted'), 'success', 'ideaboard_deleted');
return Frontcontroller::redirect(BASE_URL.'/ideas/showBoards');
}
return $this->tpl->display('ideas.delCanvas');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Symfony\Component\HttpFoundation\Response;
class DelCanvasItem extends Controller
{
private IdeaService $ideaService;
/**
* Initializes dependencies.
*/
public function init(IdeaService $ideaService): void
{
$this->ideaService = $ideaService;
}
/**
* Displays the delete idea item confirmation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::DELETE)]
public function get(array $params): Response
{
return $this->tpl->displayPartial('ideas.delCanvasItem');
}
/**
* Handles idea item deletion. The controller gate defers (entityScoped) to the service's
* deleteCanvasItem(), which authorizes DELETE against the item's REAL project.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::DELETE, entityScoped: true)]
public function post(array $params): Response
{
$id = (int) ($params['id'] ?? $_GET['id'] ?? 0);
if (isset($_POST['del']) && $id > 0) {
$this->ideaService->deleteCanvasItem($id);
$this->tpl->setNotification($this->language->__('notification.idea_board_item_deleted'), 'success', 'ideaitem_deleted');
return Frontcontroller::redirect(BASE_URL.'/ideas/showBoards');
}
return $this->tpl->displayPartial('ideas.delCanvasItem');
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
class IdeaDialog extends Controller
{
private IdeaService $ideaService;
/**
* init - initialize private variables
*/
public function init(IdeaService $ideaService): void
{
$this->ideaService = $ideaService;
}
/**
* get - handle get requests. The embedded ?delComment / ?removeMilestone mutations are fenced
* by the service (removeIdeaComment = author-or-moderate; removeMilestone = edit) in-body.
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function get($params)
{
if (isset($params['id'])) {
// Delete comment
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
$this->ideaService->removeIdeaComment($commentId);
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success', 'ideacomment_deleted');
}
// Delete milestone relationship
if (isset($params['removeMilestone']) === true) {
$this->ideaService->removeMilestone((int) $params['id']);
$this->tpl->setNotification($this->language->__('notifications.milestone_detached'), 'success');
}
$canvasItem = $this->ideaService->getIdeaItem((int) $params['id']);
$comments = $this->ideaService->getIdeaComments('idea', $canvasItem['id']);
$this->tpl->assign('numComments', $this->ideaService->countIdeaComments('ideas', $canvasItem['id']));
} else {
$type = $params['type'] ?? 'idea';
$canvasItem = $this->ideaService->getIdeaItem(null, $type);
$comments = [];
}
$this->tpl->assign('comments', $comments);
$this->tpl->assign('milestones', $this->ideaService->getProjectMilestones((int) session('currentProject')));
$this->tpl->assign('canvasTypes', $this->ideaService->getCanvasTypes());
$this->tpl->assign('canvasItem', $canvasItem);
$this->tpl->assign('currentCanvas', (int) session('currentIdeaCanvas'));
return $this->tpl->displayPartial('ideas.ideaDialog');
}
/**
* post - handle post requests. Create/update/comment all defer to the service methods, which
* authorize the correct verb (ideas.create / ideas.edit / comments.create) against the entity's
* real project.
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function post($params)
{
if (isset($params['comment']) === true) {
if ($params['text'] != '') {
$this->ideaService->addIdeaComment(
$params['text'],
(int) $_GET['id'],
$params['father'],
(int) session('currentProject'),
(int) session('userdata.id')
);
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
return Frontcontroller::redirect(BASE_URL.'/ideas/ideaDialog/'.(int) $_GET['id']);
}
}
$id = $_GET['id'] ?? null;
// changeItem is set for new or edited item changes.
if (isset($params['changeItem'])) {
if (isset($params['itemId']) && $params['itemId'] != '') {
if (isset($params['description']) === true) {
$currentCanvasId = (int) ($params['canvasId'] ?? session('currentIdeaCanvas'));
$input = [
'box' => $params['box'],
'description' => $params['description'],
'status' => $params['status'],
'data' => $params['data'],
'tags' => $params['tags'],
'itemId' => $params['itemId'],
'canvasId' => $currentCanvasId,
'milestoneId' => $params['milestoneId'],
'newMilestone' => $params['newMilestone'] ?? '',
'existingMilestone' => $params['existingMilestone'] ?? '',
];
$this->ideaService->updateIdeaItem(
$input,
(int) session('currentProject'),
(int) session('userdata.id')
);
$comments = $this->ideaService->getIdeaComments('leancanvasitem', (int) $params['itemId']);
$this->tpl->assign(
'numComments',
$this->ideaService->countIdeaComments('leancanvasitem', (int) $params['itemId'])
);
$this->tpl->assign('comments', $comments);
$this->tpl->setNotification($this->language->__('notification.idea_edited'), 'success');
return Frontcontroller::redirect(BASE_URL.'/ideas/ideaDialog/'.(int) $params['itemId']);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return Frontcontroller::redirect(BASE_URL.'/ideas/ideaDialog/');
}
} else {
if (isset($_POST['description']) === true) {
$currentCanvasId = (int) ($params['canvasId'] ?? session('currentIdeaCanvas'));
$input = [
'box' => $params['box'],
'description' => $params['description'],
'status' => $params['status'],
'data' => $params['data'],
'canvasId' => $currentCanvasId,
];
$id = $this->ideaService->createIdeaItem(
$input,
(int) session('currentProject'),
(int) session('userdata.id')
);
$this->tpl->setNotification($this->language->__('notification.idea_created'), 'success', 'idea_created');
return Frontcontroller::redirect(BASE_URL.'/ideas/ideaDialog/'.(int) $id);
} else {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return Frontcontroller::redirect(BASE_URL.'/ideas/ideaDialog/');
}
}
}
$canvasItem = $this->ideaService->getRawIdeaItem($id !== null ? (int) $id : null);
$this->tpl->assign('canvasTypes', $this->ideaService->getCanvasTypes());
$this->tpl->assign('canvasItem', $canvasItem);
return $this->tpl->displayPartial('ideas.ideaDialog');
}
/**
* put - handle put requests
*/
public function put($params) {}
/**
* delete - handle delete requests
*/
public function delete($params) {}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace Leantime\Domain\Ideas\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Services\Ideas as IdeaService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Symfony\Component\HttpFoundation\Response;
class ShowBoards extends Controller
{
private IdeaService $ideaService;
private ProjectService $projectService;
/**
* Initializes dependencies.
*/
public function init(IdeaService $ideaService, ProjectService $projectService): void
{
$this->ideaService = $ideaService;
$this->projectService = $projectService;
session(['lastPage' => CURRENT_URL]);
session(['lastIdeaView' => 'board']);
}
/**
* Displays the idea boards view.
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function get(array $params): Response
{
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display('ideas.showBoards');
}
return new Response;
}
/**
* Handles idea board mutations (create, edit, search).
*
* @param array $params Request parameters
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function post(array $params): Response
{
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$currentCanvasId = $this->resolveCurrentCanvasId($allCanvas, $params);
if (isset($_POST['searchCanvas'])) {
$currentCanvasId = (int) $_POST['searchCanvas'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
if (isset($_POST['newCanvas'])) {
$result = $this->handleNewCanvas($currentCanvasId, $allCanvas);
if ($result !== null) {
return $result;
}
}
if (isset($_POST['editCanvas']) && $currentCanvasId > 0) {
$result = $this->handleEditCanvas($currentCanvasId);
if ($result !== null) {
return $result;
}
}
$this->assignTemplateVars($currentCanvasId, $allCanvas);
if (! isset($_GET['raw'])) {
return $this->tpl->display('ideas.showBoards');
}
return new Response;
}
/**
* Resolves the current canvas ID from session or request parameters.
*
* Auto-creates a default board (via the service) when the project has none.
*/
private function resolveCurrentCanvasId(array &$allCanvas, array $params): int
{
if (! $allCanvas) {
$currentCanvasId = $this->ideaService->ensureBoardExists(
(int) session('currentProject'),
(int) session('userdata.id'),
$allCanvas
);
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
return $currentCanvasId;
}
if (session()->exists('currentIdeaCanvas')) {
$currentCanvasId = session('currentIdeaCanvas');
} else {
$currentCanvasId = -1;
session(['currentIdeaCanvas' => '']);
}
if (session('currentIdeaCanvas') == '') {
$currentCanvasId = $allCanvas[0]['id'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
if (isset($params['id'])) {
$currentCanvasId = (int) $params['id'];
session(['currentIdeaCanvas' => $currentCanvasId]);
}
return $currentCanvasId;
}
/**
* Handles creating a new idea board.
*/
private function handleNewCanvas(int &$currentCanvasId, array &$allCanvas): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$currentCanvasId = $this->ideaService->createBoard(
$_POST['canvastitle'],
(int) session('currentProject'),
(int) session('userdata.id')
);
$allCanvas = $this->ideaService->getAllBoards(session('currentProject'));
$this->tpl->setNotification($this->language->__('notification.idea_board_created'), 'success', 'idea_board_created');
session(['currentIdeaCanvas' => $currentCanvasId]);
return Frontcontroller::redirect(BASE_URL.'/ideas/showBoards/');
}
/**
* Handles editing an idea board title.
*/
private function handleEditCanvas(int &$currentCanvasId): ?Response
{
if (! isset($_POST['canvastitle']) || empty($_POST['canvastitle'])) {
$this->tpl->setNotification($this->language->__('notification.please_enter_title'), 'error');
return null;
}
$currentCanvasId = $this->ideaService->updateBoard($currentCanvasId, $_POST['canvastitle']);
$this->tpl->setNotification($this->language->__('notification.board_edited'), 'success', 'idea_board_edited');
return $this->tpl->display('canvas.boardDialog');
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(int $currentCanvasId, array $allCanvas): void
{
$this->tpl->assign('currentCanvas', $currentCanvasId);
$this->tpl->assign('canvasLabels', $this->ideaService->getBoardLabels());
$this->tpl->assign('allCanvas', $allCanvas);
$this->tpl->assign('canvasItems', $this->ideaService->getBoardItems($currentCanvasId));
$this->tpl->assign('users', $this->projectService->getUsersAssignedToProject(session('currentProject')));
}
}

View File

@@ -0,0 +1,300 @@
leantime.ideasController = (function () {
var closeModal = false;
//Variables
var canvasoptions = function () {
return {
sizes: {
minW: 700,
minH: 1000,
},
resizable: true,
autoSizable: true,
callbacks: {
beforeShowCont: function () {
jQuery(".showDialogOnLoad").show();
if (closeModal == true) {
closeModal = false;
location.reload();
}
},
afterShowCont: function () {
jQuery(".ideaModal, #commentForm, #commentForm .deleteComment, .leanCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
}
}
}
};
//Functions
var _initModals = function () {
jQuery(".ideaModal, #commentForm, #commentForm .deleteComment, .leanCanvasMilestone .deleteMilestone").nyroModal(canvasoptions());
};
var openModalManually = function (url) {
jQuery.nmManual(url, canvasoptions);
};
var initMasonryWall = function () {
var $grid = jQuery('#ideaMason').packery({
// options
itemSelector: '.ticketBox',
columnWidth: 260,
isResizable: true
});
$grid.imagesLoaded().progress(function () {
$grid.packery('layout');
});
var $items = $grid.find('.ticketBox').draggable({
start: function (event, ui) {
ui.helper.addClass('tilt');
tilt_direction(ui.helper);
},
stop: function (event, ui) {
ui.helper.removeClass("tilt");
jQuery("html").unbind('mousemove', ui.helper.data("move_handler"));
ui.helper.removeData("move_handler");
},
});
function tilt_direction(item)
{
var left_pos = item.position().left,
move_handler = function (e) {
if (e.pageX >= left_pos) {
item.addClass("right");
item.removeClass("left");
} else {
item.addClass("left");
item.removeClass("right");
}
left_pos = e.pageX;
};
jQuery("html").bind("mousemove", move_handler);
item.data("move_handler", move_handler);
}
// bind drag events to Packery
$grid.packery('bindUIDraggableEvents', $items);
function orderItems()
{
var ideaSort = [];
var itemElems = $grid.packery('getItemElements');
jQuery(itemElems).each(function ( i, itemElem ) {
var sortIndex = i + 1;
var ideaId = jQuery(itemElem).attr("data-value");
ideaSort.push({"id":ideaId, "sortIndex":sortIndex});
});
leantime.rpc('Ideas.Ideas.reorderIdeas', { payload: ideaSort })
.catch(function (e) { console.error('Could not reorder ideas', e); });
}
$grid.on('dragItemPositioned',orderItems);
};
var initBoardControlModal = function () {
};
var initWallImageModals = function () {
jQuery('.mainIdeaContent img').each(function () {
jQuery(this).wrap("<a href='" + jQuery(this).attr("src") + "' class='imageModal'></a>");
});
jQuery(".imageModal").nyroModal();
};
var toggleMilestoneSelectors = function (trigger) {
if (trigger == 'existing') {
jQuery('#newMilestone, #milestoneSelectors').hide('fast');
jQuery('#existingMilestone').show();
_initModals();
}
if (trigger == 'new') {
jQuery('#newMilestone').show();
jQuery('#existingMilestone, #milestoneSelectors').hide('fast');
_initModals();
}
if (trigger == 'hide') {
jQuery('#newMilestone, #existingMilestone').hide('fast');
jQuery('#milestoneSelectors').show('fast');
}
};
var setCloseModal = function () {
closeModal = true;
};
var initUserDropdown = function () {
jQuery("body").on(
"click",
".userDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("_");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 3) {
var canvasId = dataValue[0];
var userId = dataValue[1];
var profileImageId = dataValue[2];
leantime.rpc('Ideas.Ideas.patchIdeaItem', { id: canvasId, params: { author: userId } })
.then(function (success) {
if (! success) { return; }
jQuery("#userDropdownMenuLink" + canvasId + " span.text span#userImage" + canvasId + " img").attr("src", leantime.appUrl + "/users/profileImage/" + encodeURIComponent(userId));
jQuery.growl({message: leantime.i18n.__("short_notifications.user_updated")});
})
.catch(function (e) { console.error('Could not update idea author', e); });
}
}
);
};
var initStatusDropdown = function () {
jQuery("body").on(
"click",
".statusDropdown .dropdown-menu a",
function () {
var dataValue = jQuery(this).attr("data-value").split("_");
var dataLabel = jQuery(this).attr('data-label');
if (dataValue.length == 3) {
var canvasItemId = dataValue[0];
var status = dataValue[1];
var statusClass = dataValue[2];
leantime.rpc('Ideas.Ideas.patchIdeaItem', { id: canvasItemId, params: { box: status } })
.then(function (success) {
if (! success) { return; }
jQuery("#statusDropdownMenuLink" + canvasItemId + " span.text").text(dataLabel);
jQuery("#statusDropdownMenuLink" + canvasItemId).removeClass().addClass("" + statusClass + " dropdown-toggle f-left status ");
jQuery.growl({message: leantime.i18n.__("short_notifications.status_updated")});
})
.catch(function (e) { console.error('Could not update idea status', e); });
}
}
);
};
var setKanbanHeights = function () {
var maxHeight = 0;
var height = jQuery("html").height() - 320;
jQuery("#sortableIdeaKanban .column .contentInner").css("height", height);
};
var initIdeaKanban = function (statusList) {
jQuery("#sortableIdeaKanban").disableSelection();
jQuery("#sortableIdeaKanban .ticketBox").hover(function () {
jQuery(this).css("background", "var(--kanban-card-hover)");
},function () {
jQuery(this).css("background", "var(--kanban-card-bg)");
});
jQuery("#sortableIdeaKanban .contentInner").sortable({
connectWith: ".contentInner",
items: "> .moveable",
tolerance: 'pointer',
placeholder: "ui-state-highlight",
forcePlaceholderSize: true,
cancel: ".portlet-toggle",
start: function (event, ui) {
ui.item.addClass('tilt');
tilt_direction(ui.item);
},
stop: function (event, ui) {
ui.item.removeClass("tilt");
jQuery("html").unbind('mousemove', ui.item.data("move_handler"));
ui.item.removeData("move_handler");
},
update: function (event, ui) {
var statusPostData = {
action: "statusUpdate",
payload: {}
};
for (var i = 0; i < statusList.length; i++) {
if (jQuery(".contentInner.status_" + statusList[i]).length) {
statusPostData.payload[statusList[i]] = jQuery(".contentInner.status_" + statusList[i]).sortable('serialize');
}
}
leantime.rpc('Ideas.Ideas.bulkUpdateStatus', { payload: statusPostData.payload })
.catch(function (e) { console.error('Could not update idea statuses', e); });
}
});
function tilt_direction(item)
{
var left_pos = item.position().left,
move_handler = function (e) {
if (e.pageX >= left_pos) {
item.addClass("right");
item.removeClass("left");
} else {
item.addClass("left");
item.removeClass("right");
}
left_pos = e.pageX;
};
jQuery("html").bind("mousemove", move_handler);
item.data("move_handler", move_handler);
}
jQuery(".portlet")
.addClass("ui-widget ui-widget-content ui-helper-clearfix ui-corner-all")
.find(".portlet-header")
.addClass("ui-widget-header ui-corner-all")
.prepend("<span class='ui-icon ui-icon-minusthick portlet-toggle'></span>");
jQuery(".portlet-toggle").click(function () {
var icon = jQuery(this);
icon.toggleClass("ui-icon-minusthick ui-icon-plusthick");
icon.closest(".portlet").find(".portlet-content").toggle();
});
};
// Make public what you want to have public, everything else is private
return {
setCloseModal:setCloseModal,
toggleMilestoneSelectors: toggleMilestoneSelectors,
openModalManually:openModalManually,
initMasonryWall:initMasonryWall,
initBoardControlModal:initBoardControlModal,
initWallImageModals:initWallImageModals,
initUserDropdown:initUserDropdown,
initStatusDropdown:initStatusDropdown,
setKanbanHeights:setKanbanHeights,
initIdeaKanban:initIdeaKanban
};
})();

View File

@@ -0,0 +1,41 @@
<?php
namespace Leantime\Domain\Ideas\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Ideas permission vocabulary — the verbs only.
*
* Idea boards and items are PROJECT-scoped (each board belongs to one project; items belong to a
* board), so every capability is evaluated against the user's role IN that project (projectScoped =
* true, the default). The standard verbs auto-grant via the central matrix (readonly = view; editor
* = create/edit/delete; manager+ = all), so no
* {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions} change is required.
*/
final class IdeasPermissions implements ProvidesPermissions
{
public const VIEW = 'ideas.view';
public const CREATE = 'ideas.create';
public const EDIT = 'ideas.edit';
public const DELETE = 'ideas.delete';
public function domain(): string
{
return 'ideas';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View idea boards'),
new Permission(self::CREATE, 'Create idea boards and ideas'),
new Permission(self::EDIT, 'Edit ideas'),
new Permission(self::DELETE, 'Delete idea boards and ideas'),
];
}
}

View File

@@ -0,0 +1,452 @@
<?php
namespace Leantime\Domain\Ideas\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Language as LanguageCore;
class Ideas
{
public ?object $result = null;
public ?object $tickets = null;
private ConnectionInterface $db;
public array $canvasTypes = [
'idea' => 'status.ideation',
'research' => 'status.discovery',
'prototype' => 'status.delivering',
'validation' => 'status.inreview',
'implemented' => 'status.accepted',
'deferred' => 'status.deferred',
];
public array $statusClasses = ['idea' => 'label-info', 'validation' => 'label-warning', 'prototype' => 'label-warning', 'research' => 'label-warning', 'implemented' => 'label-success', 'deferred' => 'label-default'];
private LanguageCore $language;
private DatabaseHelper $dbHelper;
/**
* __construct - get db connection
*
* @return void
*/
public function __construct(DbCore $db, LanguageCore $language, DatabaseHelper $dbHelper)
{
$this->db = $db->getConnection();
$this->language = $language;
$this->dbHelper = $dbHelper;
}
public function getSingleCanvas(int $canvasId): false|array
{
$results = $this->db->table('zp_canvas')
->select(
'zp_canvas.id',
'zp_canvas.title',
'zp_canvas.author',
'zp_canvas.created',
'zp_canvas.projectId',
't1.firstname AS authorFirstname',
't1.lastname AS authorLastname'
)
->leftJoin('zp_user AS t1', 'zp_canvas.author', '=', 't1.id')
->where('type', 'idea')
->where('zp_canvas.id', $canvasId)
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @return array|mixed
*/
public function getCanvasLabels(): mixed
{
if (session()->exists('projectsettings.idealabels')) {
return session('projectsettings.idealabels');
} else {
$result = $this->db->table('zp_settings')
->select('value')
->where('key', 'projectsettings.'.session('currentProject').'.idealabels')
->limit(1)
->first();
$labels = [];
// preseed state labels with default values
foreach ($this->canvasTypes as $key => $label) {
$labels[$key] = [
'name' => $this->language->__($label),
'class' => $this->statusClasses[$key],
];
}
if ($result !== null) {
foreach (safe_unserialize($result->value, []) as $key => $label) {
// Ignore keys we have no status class for. Idea labels are keyed by the
// canvasTypes strings, but a bug in the rename dialog used to persist an
// integer 0 key, and reading it back threw "Undefined array key 0" on every
// render — permanently 500ing the board with no way to undo it from the UI
// (#3685). Skipping unknown keys lets an already-broken board heal itself.
if (! isset($this->statusClasses[$key])) {
continue;
}
$labels[$key] = [
'name' => $label,
'class' => $this->statusClasses[$key],
];
}
}
session(['projectsettings.idealabels' => $labels]);
return $labels;
}
}
public function getAllCanvas(int $projectId): false|array
{
$results = $this->db->table('zp_canvas')
->select(
'zp_canvas.id',
'zp_canvas.title',
'zp_canvas.author',
'zp_canvas.created',
't1.firstname AS authorFirstname',
't1.lastname AS authorLastname'
)
->leftJoin('zp_user AS t1', 'zp_canvas.author', '=', 't1.id')
->where('type', 'idea')
->where('projectId', $projectId)
->orderBy('zp_canvas.title')
->orderBy('zp_canvas.created')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function deleteCanvas(int $id): void
{
// Shared zp_canvas/zp_canvas_items: early-return unless this id is an idea board, so the
// items-delete (by canvasId) can't drop another canvas family's items before the
// type-guarded board delete runs.
$isIdea = $this->db->table('zp_canvas')
->where('id', $id)
->where('type', 'idea')
->exists();
if (! $isIdea) {
return;
}
$this->db->table('zp_canvas_items')
->where('canvasId', $id)
->delete();
$this->db->table('zp_canvas')
->where('id', $id)
->where('type', 'idea')
->delete();
}
public function addCanvas(array $values): false|string
{
$id = $this->db->table('zp_canvas')->insertGetId([
'title' => $values['title'],
'author' => $values['author'],
'created' => now(),
'type' => 'idea',
'projectId' => $values['projectId'],
]);
return (string) $id;
}
public function updateCanvas(array $values): mixed
{
// type guard (shared zp_canvas across all canvas families): only ever rename an idea board.
return $this->db->table('zp_canvas')
->where('id', $values['id'])
->where('type', 'idea')
->update(['title' => $values['title']]);
}
public function editCanvasItem(array $values): void
{
$this->db->table('zp_canvas_items')
->where('id', $values['itemId'])
->update([
'description' => $values['description'],
'assumptions' => $values['assumptions'],
'data' => $values['data'],
'conclusion' => $values['conclusion'],
'modified' => now(),
'status' => $values['status'],
'milestoneId' => $values['milestoneId'],
'tags' => $values['tags'],
]);
}
public function patchCanvasItem(int $id, array $params): bool
{
if (isset($params['act'])) {
unset($params['act']);
}
$updateData = [];
foreach ($params as $key => $value) {
$sanitizedKey = DbCore::sanitizeToColumnString($key);
$updateData[$sanitizedKey] = $value;
}
return $this->db->table('zp_canvas_items')
->where('id', $id)
->update($updateData) >= 0;
}
public function updateIdeaSorting(array $sortingArray): bool
{
foreach ($sortingArray as $idea) {
$this->db->table('zp_canvas_items')
->updateOrInsert(
['id' => (int) $idea['id']],
['sortindex' => (int) $idea['sortIndex']]
);
}
return true;
}
public function getCanvasItemsById(int $id): false|array
{
$results = $this->db->table('zp_canvas_items')
->select(
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.milestoneId',
't1.firstname AS authorFirstname',
't1.lastname AS authorLastname',
't1.profileId AS authorProfileId',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo'
)
->selectRaw("CASE WHEN zp_canvas_items.status IS NULL THEN 'idea' ELSE zp_canvas_items.status END as status")
->selectRaw('COUNT(DISTINCT zp_comment.id) AS '.$this->dbHelper->wrapColumn('commentCount'))
->leftJoin('zp_user AS t1', 'zp_canvas_items.author', '=', 't1.id')
->leftJoin('zp_tickets AS milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=', $this->db->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->leftJoin('zp_comment', function ($join) {
$join->on('zp_canvas_items.id', '=', 'zp_comment.moduleId')
->where('zp_comment.module', '=', 'idea');
})
->where('zp_canvas_items.canvasId', $id)
->groupBy(
'zp_canvas_items.id',
't1.firstname',
't1.lastname',
't1.profileId',
'milestone.headline',
'milestone.editTo',
'zp_canvas_items.status'
)
->orderBy('zp_canvas_items.sortindex')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function getSingleCanvasItem(int $id): mixed
{
$result = $this->db->table('zp_canvas_items')
->select(
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.tags',
'zp_canvas_items.milestoneId',
't1.firstname AS authorFirstname',
't1.lastname AS authorLastname',
'milestone.headline as milestoneHeadline',
'milestone.editTo as milestoneEditTo'
)
->leftJoin('zp_tickets AS milestone', function ($join) {
$join->on('zp_canvas_items.milestoneId', '=', $this->db->raw($this->dbHelper->castAs($this->dbHelper->wrapColumn('milestone.id'), 'text')));
})
->leftJoin('zp_user AS t1', 'zp_canvas_items.author', '=', 't1.id')
->where('zp_canvas_items.id', $id)
->first();
return $result ? (array) $result : false;
}
public function addCanvasItem(array $values): false|string
{
$id = $this->db->table('zp_canvas_items')->insertGetId([
'description' => $values['description'],
'assumptions' => $values['assumptions'] ?? '',
'data' => $values['data'] ?? '',
'conclusion' => $values['conclusion'] ?? '',
'box' => $values['box'] ?? 'idea',
'author' => $values['author'] ?? session('userdata.id'),
'created' => now(),
'modified' => now(),
'canvasId' => $values['canvasId'],
'status' => $values['status'] ?? '',
'milestoneId' => $values['milestoneId'] ?? '',
]);
return (string) $id;
}
public function delCanvasItem(int $id): void
{
$this->db->table('zp_canvas_items')
->where('id', $id)
->delete();
}
public function updateIdeaStatus(int $ideaId, string $status): bool
{
return $this->db->table('zp_canvas_items')
->where('id', $ideaId)
->update(['box' => $status]) >= 0;
}
/**
* @return int|mixed
*/
public function getNumberOfIdeas(?int $projectId = null): mixed
{
$query = $this->db->table('zp_canvas_items')
->leftJoin('zp_canvas AS canvasBoard', 'zp_canvas_items.canvasId', '=', 'canvasBoard.id')
->where('canvasBoard.type', 'idea');
if ($projectId !== null) {
$query->where('canvasBoard.projectId', $projectId);
}
return $query->count('zp_canvas_items.id');
}
/**
* @return int|mixed
*/
public function getNumberOfBoards(?int $projectId = null): mixed
{
$query = $this->db->table('zp_canvas')
->where('type', 'idea');
if ($projectId !== null) {
$query->where('projectId', $projectId);
}
return $query->count();
}
public function bulkUpdateIdeaStatus(array $params): bool
{
// Jquery sortable serializes the array for kanban in format
// statusKey: item[]=X&item[]=X2...,
// statusKey2: item[]=X&item[]=X2...,
// This represents status & kanban sorting
foreach ($params as $status => $ideaList) {
$ideas = explode('&', $ideaList);
foreach ($ideas as $key => $ideaString) {
if (strlen($ideaString) > 0) {
$id = substr($ideaString, 7);
if ($this->updateIdeaStatus((int) $id, $status) === false) {
return false;
}
}
}
}
return true;
}
public function getAllIdeas(?int $projectId, ?int $boardId): array|false
{
$userId = session('userdata.id') ?? -1;
$clientId = session('userdata.clientId') ?? -1;
$requesterRole = session()->exists('userdata') ? session('userdata.role') : -1;
$query = $this->db->table('zp_canvas_items')
->select(
'zp_canvas_items.id',
'zp_canvas_items.description',
'zp_canvas_items.assumptions',
'zp_canvas_items.data',
'zp_canvas_items.conclusion',
'zp_canvas_items.box',
'zp_canvas_items.author',
'zp_canvas_items.created',
'zp_canvas_items.modified',
'zp_canvas_items.canvasId',
'zp_canvas_items.sortindex',
'zp_canvas_items.status',
'zp_canvas_items.tags',
'zp_canvas_items.milestoneId',
'zp_canvas.projectId'
)
->leftJoin('zp_canvas', 'zp_canvas_items.canvasId', '=', 'zp_canvas.id')
->leftJoin('zp_projects', 'zp_canvas.projectId', '=', 'zp_projects.id')
->where('zp_canvas_items.box', 'idea')
->where(function ($q) use ($userId, $clientId, $requesterRole) {
$q->whereIn('zp_canvas.projectId', function ($subquery) use ($userId) {
$subquery->select('projectId')
->from('zp_relationuserproject')
->where('userId', $userId);
})
->orWhere('zp_projects.psettings', 'all')
->orWhere(function ($q2) use ($clientId) {
$q2->where('zp_projects.psettings', 'clients')
->where('zp_projects.clientId', $clientId);
});
// Admin and manager roles have access to all projects
if (in_array($requesterRole, ['admin', 'manager'])) {
$q->orWhereRaw('1=1');
}
});
if (isset($projectId) && $projectId > 0) {
$query->where('zp_canvas.projectId', $projectId);
}
if (isset($boardId) && $boardId > 0) {
$query->where('zp_canvas.id', $boardId);
}
$results = $query->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
}

View File

@@ -0,0 +1,983 @@
<?php
namespace Leantime\Domain\Ideas\Services;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Mailer as MailerCore;
use Leantime\Domain\Comments\Permissions\CommentsPermissions;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Ideas\Permissions\IdeasPermissions;
use Leantime\Domain\Ideas\Repositories\Ideas as IdeasRepository;
use Leantime\Domain\Notifications\Models\Notification as NotificationModel;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Queue\Repositories\Queue as QueueRepository;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
/**
* Ideas service - business logic for idea boards and idea items.
*/
class Ideas extends BaseService
{
private IdeasRepository $ideasRepository;
private CommentRepository $commentsRepository;
private ProjectService $projectService;
private TicketService $ticketService;
private LanguageCore $language;
/**
* Constructor.
*/
public function __construct(
IdeasRepository $ideasRepository,
CommentRepository $commentsRepository,
ProjectService $projectService,
TicketService $ticketService,
LanguageCore $language
) {
$this->ideasRepository = $ideasRepository;
$this->commentsRepository = $commentsRepository;
$this->projectService = $projectService;
$this->ticketService = $ticketService;
$this->language = $language;
}
/**
* Resolve an idea board's owning project. Boards are zp_canvas rows (type 'idea').
*
* @param int $boardId The board (canvas) id
* @return int|null The board's project id, or null when the board does not exist
*/
private function boardProjectId(int $boardId): ?int
{
// getSingleCanvas() returns a list of row arrays (not a flat row).
$rows = $this->ideasRepository->getSingleCanvas($boardId);
return isset($rows[0]['projectId']) ? (int) $rows[0]['projectId'] : null;
}
/**
* Resolve an idea item's owning project (item -> its board/canvas -> project).
*
* The repository operates by item id with NO project scoping, and zp_canvas_items is shared
* across all canvas types, so callers MUST fail closed on a null result before any read/write.
*
* @param int $itemId The canvas item id
* @return int|null The item's project id, or null when it does not resolve to an idea board
*/
private function canvasItemProjectId(int $itemId): ?int
{
$item = $this->ideasRepository->getSingleCanvasItem($itemId);
if (! is_array($item) || empty($item['canvasId'])) {
return null;
}
return $this->boardProjectId((int) $item['canvasId']);
}
/**
* Authorized JSON-RPC entry point: persist a new idea sort order.
*
* Requires editor+ and per-item project access (JSON-RPC has no controller
* gate, and the repository sorts by item id with no project scoping).
*
* @param array $payload List of { id, sortIndex } entries
* @return bool True on success, false if unauthorized or the update failed
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function reorderIdeas(array $payload): bool
{
// Per-item project fence: reject the whole batch unless the user may edit EVERY item's
// project (non-throwing can(), since a batch should fail gracefully). null project = the id
// is not an idea item (shared zp_canvas_items) -> reject.
foreach ($payload as $idea) {
if (! isset($idea['id'])) {
return false;
}
$projectId = $this->canvasItemProjectId((int) $idea['id']);
if ($projectId === null || ! $this->can(IdeasPermissions::EDIT, $projectId)) {
return false;
}
}
return $this->ideasRepository->updateIdeaSorting($payload);
}
/**
* Authorized JSON-RPC entry point: bulk status/sort update from the idea kanban.
*
* Requires editor+ and project access for every item in the (jQuery-sortable
* serialized) payload.
*
* @param array $payload Map of statusKey => "item[]=ID&item[]=ID2..."
* @return bool True on success, false if unauthorized or the update failed
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function bulkUpdateStatus(array $payload): bool
{
// Per-item project fence over the jQuery-sortable serialized payload.
foreach ($payload as $itemList) {
foreach (explode('&', (string) $itemList) as $itemString) {
// jQuery sortable serializes as "item[]=ID"; strip the prefix.
$id = (int) substr($itemString, 7);
if ($id <= 0) {
continue;
}
$projectId = $this->canvasItemProjectId($id);
if ($projectId === null || ! $this->can(IdeasPermissions::EDIT, $projectId)) {
return false;
}
}
}
return $this->ideasRepository->bulkUpdateIdeaStatus($payload);
}
/**
* Authorized JSON-RPC entry point: patch a single idea/canvas item.
*
* Requires editor+ and access to the item's project.
*
* @param int $id The canvas item id
* @param array $params Fields to update
* @return bool True on success, false if unauthorized or the update failed
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function patchIdeaItem(int $id, array $params): bool
{
// Fail closed: resolve the item's REAL project (shared zp_canvas_items) and require edit
// there before patching — null means the id is not an idea item, so refuse.
$projectId = $this->canvasItemProjectId($id);
if ($projectId === null) {
return false;
}
$this->authorize(IdeasPermissions::EDIT, $projectId);
// Strip relocation/identity fields: patchCanvasItem updates any column it receives, so a
// JSON-RPC caller could otherwise patch canvasId to move the item to another board/project
// (bypassing the project scoping just authorized) or rewrite its id/author.
unset($params['canvasId'], $params['id'], $params['author']);
return $this->ideasRepository->patchCanvasItem($id, $params);
}
/**
* Polls for new ideas in a project / board, normalizing dates for the API.
*
* @param int|null $projectId Project to filter by, or null for all accessible projects.
* @param int|null $board Board to filter by, or null for all boards.
* @return array<int, array<string, mixed>> List of ideas with ISO8601 dates.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForNewIdeas(?int $projectId = null, ?int $board = null): array
{
$ideas = $this->ideasRepository->getAllIdeas($projectId, $board);
foreach ($ideas as $key => $idea) {
$ideas[$key] = $this->prepareDatesForApiResponse($idea);
}
return $ideas;
}
/**
* Polls for updated ideas, appending the modified timestamp to the id for change detection.
*
* @param int|null $projectId Project to filter by, or null for all accessible projects.
* @param int|null $board Board to filter by, or null for all boards.
* @return array<int, array<string, mixed>> List of ideas with ISO8601 dates and composite ids.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, projectIdParam: 'projectId')]
public function pollForUpdatedIdeas(?int $projectId = null, ?int $board = null): array
{
$ideas = $this->ideasRepository->getAllIdeas($projectId, $board);
foreach ($ideas as $key => $idea) {
$ideas[$key] = $this->prepareDatesForApiResponse($idea);
$ideas[$key]['id'] = $idea['id'].'-'.$idea['modified'];
}
return $ideas;
}
/**
* Normalizes a single idea's created/modified dates to ISO8601 Zulu strings.
*
* @param array<string, mixed> $idea Raw idea row.
* @return array<string, mixed> Idea with normalized dates.
*/
private function prepareDatesForApiResponse(array $idea): array
{
if (dtHelper()->isValidDateString($idea['created'])) {
$idea['created'] = dtHelper()->parseDbDateTime($idea['created'])->toIso8601ZuluString();
} else {
$idea['created'] = null;
}
if (dtHelper()->isValidDateString($idea['modified'])) {
$idea['modified'] = dtHelper()->parseDbDateTime($idea['modified'])->toIso8601ZuluString();
} else {
$idea['modified'] = null;
}
return $idea;
}
/**
* Returns all idea boards for a project.
*
* @param int $projectId Project id.
* @return array<int, array<string, mixed>> List of boards.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, projectIdParam: 'projectId')]
public function getAllBoards(int $projectId): array
{
$allCanvas = $this->ideasRepository->getAllCanvas($projectId);
return $allCanvas === false ? [] : $allCanvas;
}
/**
* Returns a single idea board by id.
*
* @param int $id Board id.
* @return array<int, array<string, mixed>> Single board rows (matching repository shape).
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getBoard(int $id): array
{
$singleCanvas = $this->ideasRepository->getSingleCanvas($id);
if (empty($singleCanvas)) {
return [];
}
// IDOR fence: authorize VIEW against the board's real project (single-entity-by-id read).
$this->authorize(IdeasPermissions::VIEW, isset($singleCanvas[0]['projectId']) ? (int) $singleCanvas[0]['projectId'] : null);
return $singleCanvas;
}
/**
* Returns the title of a single idea board, or an empty string if not found.
*
* @param int $id Board id.
* @return string Board title.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getBoardTitle(int $id): string
{
// Delegates to getBoard(), which performs the in-body VIEW authorize against the board's project.
$singleCanvas = $this->getBoard($id);
return $singleCanvas[0]['title'] ?? '';
}
/**
* Returns the canvas items for a board.
*
* @param int $boardId Board id.
* @return array<int, array<string, mixed>> Canvas items.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getBoardItems(int $boardId): array
{
// IDOR fence: authorize VIEW against the board's real project before listing its items.
$projectId = $this->boardProjectId($boardId);
if ($projectId === null) {
return [];
}
$this->authorize(IdeasPermissions::VIEW, $projectId);
$items = $this->ideasRepository->getCanvasItemsById($boardId);
return $items === false ? [] : $items;
}
/**
* Returns the configured idea status labels.
*
* @return mixed Label structure (array keyed by status).
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function getBoardLabels(): mixed
{
return $this->ideasRepository->getCanvasLabels();
}
/**
* Creates a new idea board, queuing a creation notification to project users.
*
* @param string $title Board title.
* @param int $projectId Project the board belongs to.
* @param int $authorId Author user id.
* @return int The new board id.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::CREATE, entityScoped: true)]
public function createBoard(string $title, int $projectId, int $authorId): int
{
// A board belongs directly to $projectId; authorize CREATE there before writing.
$this->authorize(IdeasPermissions::CREATE, $projectId);
$values = [
'title' => $title,
'author' => $authorId,
'projectId' => $projectId,
];
$boardId = (int) $this->ideasRepository->addCanvas($values);
$this->notifyBoardCreated($title, $projectId);
return $boardId;
}
/**
* Creates a new idea board from the board dialog, queuing the dialog-specific
* creation notification to project users.
*
* This intentionally uses a different notification subject/message than
* {@see self::createBoard()} to preserve the historical board-dialog behavior.
*
* @param string $title Board title.
* @param int $projectId Project the board belongs to.
* @param int $authorId Author user id.
* @return int The new board id.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::CREATE, entityScoped: true)]
public function createBoardFromDialog(string $title, int $projectId, int $authorId): int
{
// A board belongs directly to $projectId; authorize CREATE there before writing.
$this->authorize(IdeasPermissions::CREATE, $projectId);
$values = [
'title' => $title,
'author' => $authorId,
'projectId' => $projectId,
];
$boardId = (int) $this->ideasRepository->addCanvas($values);
$this->notifyBoardCreatedFromDialog($title, $projectId);
return $boardId;
}
/**
* Updates an idea board title.
*
* @param int $id Board id.
* @param string $title New title.
* @return mixed Result of the underlying update.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function updateBoard(int $id, string $title): mixed
{
// Fail closed: resolve the board's real project and require edit there before renaming.
$projectId = $this->boardProjectId($id);
if ($projectId === null) {
return false;
}
$this->authorize(IdeasPermissions::EDIT, $projectId);
return $this->ideasRepository->updateCanvas(['title' => $title, 'id' => $id]);
}
/**
* Ensures at least one board exists for a project, creating a default one if none do.
*
* Returns the id of the newly created default board if one was created, or 0 if boards
* already existed (no board created).
*
* @param int $projectId Project id.
* @param int $authorId Author user id for the default board.
* @param array<int, array<string, mixed>> $allBoards Already-loaded boards for the project.
* @return int Newly created board id, or 0 if none was created.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, projectIdParam: 'projectId')]
public function ensureBoardExists(int $projectId, int $authorId, array $allBoards): int
{
if (count($allBoards) > 0) {
return 0;
}
// Bootstrap only: the default board is created as a SIDE EFFECT of viewing a board-less
// project, so it is VIEW-gated and writes straight to the repository — gating it CREATE
// would 403 a readonly viewer (mirrors Wiki's getAllProjectWikis default-notebook bootstrap).
$values = [
'title' => $this->language->__('label.board'),
'author' => $authorId,
'projectId' => $projectId,
];
return (int) $this->ideasRepository->addCanvas($values);
}
/**
* Sends (queues) a board-creation notification to project users.
*
* @param string $title Board title.
* @param int $projectId Project id.
*/
private function notifyBoardCreated(string $title, int $projectId): void
{
$mailer = app()->make(MailerCore::class);
$mailer->setContext('idea_board_created');
$users = $this->projectService->getUsersToNotify($projectId);
$mailer->setSubject($this->language->__('email_notifications.idea_board_created_subject'));
$message = sprintf(
$this->language->__('email_notifications.idea_board_created_message'),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($title).'</a>.<br />'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepository::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__('email_notifications.idea_board_created_subject'),
$projectId
);
}
/**
* Sends (queues) the board-dialog-specific board-creation notification to project users.
*
* @param string $title Board title.
* @param int $projectId Project id.
*/
private function notifyBoardCreatedFromDialog(string $title, int $projectId): void
{
$mailer = app()->make(MailerCore::class);
$users = $this->projectService->getUsersToNotify($projectId);
$mailer->setSubject($this->language->__('notification.board_created'));
$message = sprintf(
$this->language->__('email_notifications.canvas_created_message'),
session('userdata.name'),
"<a href='".CURRENT_URL."'>".strip_tags($title).'</a>'
);
$mailer->setHtml($message);
$queue = app()->make(QueueRepository::class);
$queue->queueMessageToUsers(
$users,
$message,
$this->language->__('notification.board_created'),
$projectId
);
}
/**
* Loads a single idea item for display, normalizing the box value and
* returning an empty-item default when no id is supplied.
*
* @param int|null $id Idea item id, or null when creating a new item.
* @param string $type Default box/type for a new item.
* @return array<string, mixed> Idea item (normalized) or empty-item default.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getIdeaItem(?int $id, string $type = 'idea'): array
{
if ($id === null) {
return [
'id' => '',
'box' => $type,
'tags' => '',
'description' => '',
'status' => 'idea',
'assumptions' => '',
'data' => '',
'conclusion' => '',
'milestoneHeadline' => '',
'milestoneId' => '',
];
}
// IDOR fence (single-entity-by-id read): fetch the item ONCE, derive its project from the
// owning board, authorize VIEW, then return the already-fetched row — no duplicate query.
$canvasItem = $this->ideasRepository->getSingleCanvasItem($id);
if (! is_array($canvasItem) || empty($canvasItem['canvasId'])) {
return [];
}
$projectId = $this->boardProjectId((int) $canvasItem['canvasId']);
if ($projectId === null) {
return [];
}
$this->authorize(IdeasPermissions::VIEW, $projectId);
if (isset($canvasItem['box']) && $canvasItem['box'] == '0') {
$canvasItem['box'] = 'idea';
}
return $canvasItem;
}
/**
* Returns a single raw idea item without the display normalization applied by
* {@see self::getIdeaItem()}. A null id is coerced to 0 (no match), mirroring
* the historical repository call.
*
* @param int|null $id Idea item id.
* @return mixed The idea item array, or false when not found.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getRawIdeaItem(?int $id): mixed
{
// IDOR fence (single-entity-by-id read): fetch the item ONCE, derive its project from the
// owning board, authorize VIEW, then return the already-fetched row — no duplicate query.
$canvasItem = $this->ideasRepository->getSingleCanvasItem((int) $id);
if (! is_array($canvasItem) || empty($canvasItem['canvasId'])) {
return false;
}
$projectId = $this->boardProjectId((int) $canvasItem['canvasId']);
if ($projectId === null) {
return false;
}
$this->authorize(IdeasPermissions::VIEW, $projectId);
return $canvasItem;
}
/**
* Returns the canvas type map (status keys to label keys).
*
* @return array<string, string> Canvas types.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW)]
public function getCanvasTypes(): array
{
return $this->ideasRepository->canvasTypes;
}
/**
* Creates a new idea item and queues a creation notification to project users.
*
* @param array<string, mixed> $input Idea item input (box, description, status, data, canvasId, ...).
* @param int $projectId Project id for the notification.
* @param int $authorId Author user id.
* @return int The new idea item id.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::CREATE, entityScoped: true)]
public function createIdeaItem(array $input, int $projectId, int $authorId): int
{
// Authorize CREATE against the TARGET board's real project (resolved from canvasId; the
// passed projectId is untrusted). FAIL CLOSED if the canvasId is not an idea board — never
// fall back to the caller-supplied projectId, or a foreign/non-idea canvasId could be
// created against the caller's own project.
$boardProjectId = $this->boardProjectId((int) ($input['canvasId'] ?? 0));
if ($boardProjectId === null) {
return 0;
}
$this->authorize(IdeasPermissions::CREATE, $boardProjectId);
// Normalize to the resolved board project so the notification below targets the correct
// project's users even if a mismatched/forged projectId was supplied.
$projectId = $boardProjectId;
$canvasItem = [
'box' => $input['box'],
'author' => $authorId,
'description' => $input['description'],
'status' => $input['status'],
'assumptions' => '',
'data' => $input['data'],
'conclusion' => '',
'canvasId' => $input['canvasId'],
];
$id = (int) $this->ideasRepository->addCanvasItem($canvasItem);
$canvasItem['id'] = $id;
$subject = $this->language->__('email_notifications.idea_created_subject');
$actualLink = BASE_URL.'#/ideas/ideaDialog/'.$id;
$message = sprintf(
$this->language->__('email_notifications.idea_created_message'),
session('userdata.name'),
strip_tags($input['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.idea_created_subject'),
];
$notification->entity = $canvasItem;
$notification->module = 'ideas';
$notification->action = 'created';
$notification->projectId = $projectId;
$notification->subject = $subject;
$notification->authorId = $authorId;
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return $id;
}
/**
* Updates an existing idea item, optionally quick-adding or attaching a milestone,
* then queues an edit notification to project users.
*
* @param array<string, mixed> $input Idea item input including itemId and optional milestone fields.
* @param int $projectId Project id for the notification.
* @param int $authorId Author user id.
* @return int The idea item id that was updated.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function updateIdeaItem(array $input, int $projectId, int $authorId): int
{
$itemId = (int) $input['itemId'];
// Fail closed: resolve the EXISTING item's real project (shared zp_canvas_items) and require
// edit there before writing — the passed projectId/canvasId are untrusted, so an editor in
// project A cannot edit or relocate an item in project B.
$existingProjectId = $this->canvasItemProjectId($itemId);
if ($existingProjectId === null) {
return 0;
}
$this->authorize(IdeasPermissions::EDIT, $existingProjectId);
// The write persists $input['canvasId'], which can RELOCATE the item to another board. The
// target must be an idea board the user can also edit, or the relocation is a cross-project
// move. Fail closed if it's not an idea board; require edit on the target if it differs.
$targetProjectId = $this->boardProjectId((int) ($input['canvasId'] ?? 0));
if ($targetProjectId === null) {
return 0;
}
if ($targetProjectId !== $existingProjectId) {
$this->authorize(IdeasPermissions::EDIT, $targetProjectId);
}
// Normalize to the resolved board project for the notification below.
$projectId = $targetProjectId;
$canvasItem = [
'box' => $input['box'],
'author' => $authorId,
'description' => $input['description'],
'status' => $input['status'],
'assumptions' => '',
'data' => $input['data'],
'conclusion' => '',
'tags' => $input['tags'],
'itemId' => $input['itemId'],
'canvasId' => $input['canvasId'],
'milestoneId' => $input['milestoneId'],
'id' => $input['itemId'],
];
if (isset($input['newMilestone']) && $input['newMilestone'] != '') {
$milestone = [];
$milestone['headline'] = $input['newMilestone'];
$milestone['tags'] = '#ccc';
$milestone['editFrom'] = dtHelper()->userNow()->formatDateForUser();
$milestone['editTo'] = dtHelper()->userNow()->addDays(7)->formatDateForUser();
$milestoneId = $this->ticketService->quickAddMilestone($milestone);
if ($milestoneId !== false) {
$canvasItem['milestoneId'] = $milestoneId;
}
}
if (isset($input['existingMilestone']) && $input['existingMilestone'] != '') {
$canvasItem['milestoneId'] = $input['existingMilestone'];
}
$this->ideasRepository->editCanvasItem($canvasItem);
$subject = $this->language->__('email_notifications.idea_edited_subject');
$actualLink = BASE_URL.'#/ideas/ideaDialog/'.$itemId;
$message = sprintf(
$this->language->__('notification.idea_edited'),
session('userdata.name'),
strip_tags($input['description'])
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.idea_edited_cta'),
];
$notification->entity = $canvasItem;
$notification->module = 'ideas';
$notification->action = 'updated';
$notification->projectId = $projectId;
$notification->subject = $subject;
$notification->authorId = $authorId;
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return $itemId;
}
/**
* Detaches a milestone from an idea item.
*
* @param int $ideaItemId Idea item id.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::EDIT, entityScoped: true)]
public function removeMilestone(int $ideaItemId): void
{
// Fail closed: resolve the item's real project and require edit there before detaching.
$projectId = $this->canvasItemProjectId($ideaItemId);
if ($projectId === null) {
return;
}
$this->authorize(IdeasPermissions::EDIT, $projectId);
$this->ideasRepository->patchCanvasItem($ideaItemId, ['milestoneId' => '']);
}
/**
* Returns all milestones available to attach to an idea item, for the current project.
*
* @param int $projectId Project id.
* @return array<int, mixed>|false Milestones.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, projectIdParam: 'projectId')]
public function getProjectMilestones(int $projectId): array|false
{
return $this->ticketService->getAllMilestones([
'sprint' => '',
'type' => 'milestone',
'currentProject' => $projectId,
]);
}
/**
* Adds a comment to an idea item and queues a notification to project users.
*
* @param string $text Comment text.
* @param int $ideaItemId Idea item (module) id.
* @param int|string $parentCommentId Parent comment id ('father').
* @param int $projectId Project id for the notification.
* @param int $authorId Author user id.
* @return false|string The new comment id, or false on failure.
*
* @api
*/
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
public function addIdeaComment(string $text, int $ideaItemId, int|string $parentCommentId, int $projectId, int $authorId): false|string
{
// Commenting on an idea is a commenter+ capability in the idea's project (resolved from the
// item; the passed projectId is untrusted). FAIL CLOSED if the id is not an idea item —
// never fall back to the caller-supplied projectId.
$itemProjectId = $this->canvasItemProjectId($ideaItemId);
if ($itemProjectId === null) {
return false;
}
$this->authorize(CommentsPermissions::CREATE, $itemProjectId);
$values = [
'text' => $text,
'date' => date('Y-m-d H:i:s'),
'userId' => $authorId,
'moduleId' => $ideaItemId,
'commentParent' => $parentCommentId,
];
$commentId = $this->commentsRepository->addComment($values, 'idea');
$values['id'] = $commentId;
$subject = $this->language->__('email_notifications.new_comment_idea_subject');
$actualLink = BASE_URL.'#/ideas/ideaDialog/'.$ideaItemId;
$message = sprintf(
$this->language->__('email_notifications.new_comment_idea_message'),
session('userdata.name')
);
$notification = app()->make(NotificationModel::class);
$notification->url = [
'url' => $actualLink,
'text' => $this->language->__('email_notifications.new_comment_idea_cta'),
];
$notification->entity = $values;
$notification->module = 'comments';
$notification->action = 'commented';
$notification->projectId = $projectId;
$notification->subject = $subject;
$notification->authorId = $authorId;
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return $commentId;
}
/**
* Deletes a comment.
*
* @param int $commentId Comment id.
*
* @api
*/
#[RequiresPermission(CommentsPermissions::MODERATE, entityScoped: true)]
public function removeIdeaComment(int $commentId): void
{
// Was an UNGATED delete-by-id (reachable via the ?delComment GET param) — any user could
// delete any comment by id. Fence: the comment author may delete their own; otherwise
// require moderate in the idea's project (resolved from the comment's idea item).
$comment = $this->commentsRepository->getComment($commentId);
if (! $comment) {
return;
}
if ((int) ($comment['userId'] ?? 0) !== (int) session('userdata.id')) {
// Non-author: require moderate in the idea's project. FAIL CLOSED if the comment's item
// does not resolve to an idea board — never downgrade to a role-only moderate check.
$projectId = $this->canvasItemProjectId((int) ($comment['moduleId'] ?? 0));
if ($projectId === null) {
return;
}
$this->authorize(CommentsPermissions::MODERATE, $projectId);
}
$this->commentsRepository->deleteComment($commentId);
}
/**
* Returns the comments for a module/entity.
*
* @param string $module Comment module key.
* @param int $entityId Entity id.
* @return array<int, mixed>|false Comments.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function getIdeaComments(string $module, int $entityId): array|false
{
// Fail closed: a null project means $entityId is not an idea item, so refuse rather than
// authorize against null (role-only pass) and leak another project's comment thread by id.
$projectId = $this->canvasItemProjectId($entityId);
if ($projectId === null) {
return [];
}
$this->authorize(IdeasPermissions::VIEW, $projectId);
return $this->commentsRepository->getComments($module, $entityId);
}
/**
* Returns the number of comments for a module/entity.
*
* @param string $module Comment module key.
* @param int $entityId Entity id.
* @return mixed Comment count.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::VIEW, entityScoped: true)]
public function countIdeaComments(string $module, int $entityId): mixed
{
// Fail closed: a null project means $entityId is not an idea item.
$projectId = $this->canvasItemProjectId($entityId);
if ($projectId === null) {
return 0;
}
$this->authorize(IdeasPermissions::VIEW, $projectId);
return $this->commentsRepository->countComments($module, $entityId);
}
/**
* Delete an idea board, fencing the operation against the board's project.
*
* Replaces the previous controller->repository direct delete (which only had a global-role
* gate and no project scoping, so an editor in any project could delete another's board).
*
* @param int $id Board (canvas) id.
* @return bool True when deleted, false when the board does not resolve to a project.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::DELETE, entityScoped: true)]
public function deleteCanvas(int $id): bool
{
$projectId = $this->boardProjectId($id);
if ($projectId === null) {
return false;
}
$this->authorize(IdeasPermissions::DELETE, $projectId);
$this->ideasRepository->deleteCanvas($id);
session()->forget('currentIdeaCanvas');
return true;
}
/**
* Delete an idea item, fencing the operation against the item's project.
*
* Replaces the previous controller->repository direct delete (id-only, no project scoping).
*
* @param int $id Canvas item id.
* @return bool True when deleted, false when the item does not resolve to a project.
*
* @api
*/
#[RequiresPermission(IdeasPermissions::DELETE, entityScoped: true)]
public function deleteCanvasItem(int $id): bool
{
$projectId = $this->canvasItemProjectId($id);
if ($projectId === null) {
return false;
}
$this->authorize(IdeasPermissions::DELETE, $projectId);
$this->ideasRepository->delCanvasItem($id);
return true;
}
}

View File

@@ -0,0 +1,299 @@
@extends($layout)
@section('content')
@php
$allCanvas = $allCanvas ?? [];
$canvasLabels = $canvasLabels ?? [];
$canvasTitle = '';
// All states >0 (<1 is archive)
$numberofColumns = count($canvasLabels);
$size = floor((100 / $numberofColumns) * 100) / 100;
// get canvas title
foreach ($allCanvas as $canvasRow) {
if ($canvasRow['id'] == ($currentCanvas ?? '')) {
$canvasTitle = $canvasRow['title'];
break;
}
}
@endphp
<div class="pageheader">
<div class="pageicon"><i class="far fa-lightbulb"></i></div>
<div class="pagetitle">
@if (count($allCanvas) > 0)
<x-global::subjectSwitcher
:parent="__('headlines.idea_management')"
:current="$canvasTitle">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="javascript:void(0)" class="addCanvasLink">{!! __('links.icon.create_new_board') !!}</a></li>
@endif
<li class="border"></li>
@foreach ($allCanvas as $canvasRow)
<li><a href='{{ BASE_URL }}/ideas/showBoards/{{ $canvasRow['id'] }}'>{{ $tpl->escape($canvasRow['title']) }}</a></li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{!! __('headlines.idea_management') !!}</h1>
@endif
</div>
@if (count($allCanvas) > 0)
<div class="pageheader-right">
<span class="dropdown dropdownWrapper headerEditDropdown">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown"><i class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="javascript:void(0)" class="editCanvasLink ">{!! __('links.icon.edit') !!}</a></li>
<li><a href="{{ BASE_URL }}/ideas/delCanvas/{{ $currentCanvas }}" class="delete">{!! __('links.icon.delete') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-4">
@if ($login::userIsAtLeast($roles::$editor))
@if (count($allCanvas) > 0)
<x-global::forms.button tag="a" link="#/ideas/ideaDialog?type=idea" contentRole="primary" id="customersegment"><span
class="far fa-lightbulb"></span>{!! __('buttons.add_idea') !!}</x-global::forms.button>
@endif
@endif
</div>
<div class="col-md-4 center">
</div>
<div class="col-md-4">
<div class="pull-right">
<div class="btn-group viewDropDown">
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown">{!! __('buttons.idea_kanban') !!} {!! __('links.view') !!}</button>
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/ideas/showBoards" >{!! __('buttons.idea_wall') !!}</a></li>
<li><a href="{{ BASE_URL }}/ideas/advancedBoards" class="active">{!! __('buttons.idea_kanban') !!}</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="clearfix"></div>
@if (count($allCanvas) > 0)
<div id="sortableIdeaKanban" class="sortableTicketList">
<div class="row-fluid">
@foreach ($canvasLabels as $key => $statusRow)
<div class="column" style="width:{{ $size }}%;">
<h4 class="widgettitle title-primary">
@if ($login::userIsAtLeast($roles::$manager))
<a href="#/setting/editBoxLabel?module=idealabels&label={{ $key }}"
class="editHeadline"><i class="fas fa-edit"></i></a>
@endif
{{ $statusRow['name'] }}
</h4>
<div class="contentInner status_{{ $key }}">
@foreach ($canvasItems as $row)
@if ($row['box'] == $key)
<div class="ticketBox moveable" id="item_{{ $row['id'] }}">
<div class="row">
<div class="col-md-12">
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
@endif
@if ($login::userIsAtLeast($roles::$editor))
&nbsp;&nbsp;&nbsp;
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.edit') !!}</li>
<li><a href="#/ideas/ideaDialog/{{ $row['id'] }}" class="" data="item_{{ $row['id'] }}"> {!! __('links.edit_canvas_item') !!}</a></li>
<li><a href="#/ideas/delCanvasItem/{{ $row['id'] }}" class="delete" data="item_{{ $row['id'] }}"> {!! __('links.delete_canvas_item') !!}</a></li>
</ul>
</div>
@endif
<h4><a href="{{ BASE_URL }}/ideas/advancedBoards/#/ideas/ideaDialog/{{ $row['id'] }}" class=""
data="item_{{ $row['id'] }}">{{ $tpl->escape($row['description']) }}</a></h4>
<div class="mainIdeaContent">
<div class="kanbanCardContent">
<div class="kanbanContent" style="margin-bottom: 20px">
{!! $tpl->escapeMinimal($row['data']) !!}
</div>
</div>
</div>
<div class="clearfix" style="padding-bottom: 8px;"></div>
<div class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
<a class="dropdown-toggle f-left" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if ($row['authorFirstname'] != '')
{!! "<span id='userImage" . $row['id'] . "'><img src='" . BASE_URL . "/api/users?profileImage=" . $row['author'] . "' width='25' style='vertical-align: middle;'/></span><span id='user" . $row['id'] . "'></span>" !!}
@else
{!! "<span id='userImage" . $row['id'] . "'><img src='" . BASE_URL . "/api/users?profileImage=false' width='25' style='vertical-align: middle;'/></span><span id='user" . $row['id'] . "'></span>" !!}
@endif
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@foreach ($users as $user)
{!! "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='" . sprintf(__('text.full_name'), $tpl->escape($user['firstname']), $tpl->escape($user['lastname'])) . "' data-value='" . $row['id'] . '_' . $user['id'] . '_' . $user['profileId'] . "' id='userStatusChange" . $row['id'] . $user['id'] . "' ><img src='" . BASE_URL . "/api/users?profileImage=" . $user['id'] . "' width='25' style='vertical-align: middle; margin-right:5px;'/>" . sprintf(__('text.full_name'), $tpl->escape($user['firstname']), $tpl->escape($user['lastname'])) . "</a></li>" !!}
@endforeach
</ul>
</div>
<div class="pull-right" style="margin-right:10px;">
<a href="#/ideas/ideaDialog/{{ $row['id'] }}"
data="item_{{ $row['id'] }}"
{!! $row['commentCount'] == 0 ? 'style="color: grey;"' : '' !!}>
<span class="fas fa-comments"></span></a> <small>{{ $row['commentCount'] }}</small>
</div>
</div>
</div>
@if ($row['milestoneHeadline'] != '')
<br/>
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $row['milestoneId'] }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
@endif
</div>
@endif
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="clearfix"></div>
@else
<br/><br/>
<div class='center'>
<div style='width:50%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_new_ideas_jdea.svg') !!}
</div>
<br/><h4>{!! __('headlines.have_an_idea') !!}</h4><br/>
{!! __('subtitles.start_collecting_ideas') !!}<br/><br/>
@if ($login::userIsAtLeast($roles::$editor))
<x-global::forms.button tag="a" link="javascript:void(0);"
class="addCanvasLink" contentRole="primary">{!! __('buttons.start_new_idea_board') !!}</x-global::forms.button>
@endif
</div>
@endif
<!-- Modals -->
<div class="modal fade bs-example-modal-lg" id="addCanvas">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form action="" method="post">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">{!! __('headlines.start_new_idea_board') !!}</h4>
</div>
<div class="modal-body">
<label>{!! __('label.topic_idea_board') !!}</label>
<x-global::forms.text-input name="canvastitle"
placeholder="{{ __('input.placeholders.name_for_idea_board') }}"
style="width:90%" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">{!! __('buttons.close') !!}</button>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary"
:labelText="__('buttons.create_board')" name="newCanvas"/>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<div class="modal fade bs-example-modal-lg" id="editCanvas">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form action="" method="post">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">{!! __('headlines.edit_board_name') !!}</h4>
</div>
<div class="modal-body">
<label>{!! __('label.title_idea_board') !!}</label>
<x-global::forms.text-input name="canvastitle" value="{{ $canvasTitle }}"
style="width:90%" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">{!! __('buttons.close') !!}</button>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')"
name="editCanvas"/>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function () {
leantime.ideasController.initBoardControlModal();
leantime.ideasController.setKanbanHeights();
@if ($login::userIsAtLeast($roles::$editor))
var ideaStatusList = [@foreach ($canvasLabels as $key => $statusRow)'{{ $key }}',@endforeach];
leantime.ideasController.initIdeaKanban(ideaStatusList);
leantime.ideasController.initUserDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,34 @@
<?php
/**
* modals.inc template - Generic template for create / edit / clone modals
*/
foreach ($__data as $var => $val) {
$$var = $val; // necessary for blade refactor
}
$allCanvas = $tpl->get('allCanvas');
$canvasTitle = $tpl->get('canvasTitle');
$canvasName = $tpl->get('canvasName');
?>
<form action="<?= BASE_URL ?>/ideas/boardDialog<?= isset($_GET['id']) ? '/'.(int) $_GET['id'] : ''?>" method="post" class="formModal">
<div class="modal-header">
<h4 class="modal-title"><i class='fa fa-plus'></i> <?= $tpl->__('subtitles.create_new_board') ?></h4>
</div>
<div class="modal-body">
<label><?= $tpl->__('label.title_new') ?></label><br />
<input type="text" name="canvastitle" value="<?= $tpl->escape($canvasTitle) ?>" placeholder="<?= $tpl->__('input.placeholders.enter_title_for_board') ?>"
style="width: 100%"/>
</div>
<div class="modal-footer">
<?php if (isset($_GET['id'])) {?>
<input type="submit" class="btn btn-primary" value="<?= $tpl->__('buttons.save_board') ?>" name="newCanvas" />
<input type="hidden" name="editCanvas" value="<?= (int) $_GET['id'] ?>">
<?php } else { ?>
<input type="hidden" name="newCanvas" value="true">
<input type="submit" class="btn btn-primary" value="<?= $tpl->__('buttons.create_board') ?>" name="newCanvas" />
<?php } ?>
<button type="button" class="btn btn-default" onclick="jQuery.nmTop().close();"><?= $tpl->__('buttons.close') ?></button>
</div>
</form>

View File

@@ -0,0 +1,26 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa fa-trash"></span></div>
<div class="pagetitle">
<h5>{{ session('currentProjectClient') . ' // ' . session('currentProjectName') }}</h5>
<h1>{!! __('headline.delete_board') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
<h4 class="widget widgettitle">{!! __('subtitles.delete') !!}</h4>
<div class="widgetcontent">
<form method="post" action="{{ BASE_URL }}/ideas/delCanvas/{{ $tpl->escape($_GET['id']) }}">
<p>{!! __('text.are_you_sure_delete_idea_board') !!}</p>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/ideas/showBoards">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,13 @@
@extends($layout)
@section('content')
<h4 class="widgettitle title-light"><i class="fa fa-trash"></i> {!! __('buttons.delete') !!}</h4>
<form method="post" action="{{ BASE_URL }}/ideas/delCanvasItem/{{ (int) $_GET['id'] }}">
<p>{!! __('text.are_you_sure_delete_idea') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/ideas/showBoards/">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
@endsection

View File

@@ -0,0 +1,175 @@
@extends($layout)
@section('content')
@php
$canvasItem = $canvasItem ?? [];
$canvasTypes = $canvasTypes ?? [];
$id = '';
if (isset($canvasItem['id']) && $canvasItem['id'] != '') {
$id = $canvasItem['id'];
}
@endphp
{!! $tpl->displayNotification() !!}
<form class="formModal" method="post" action="{{ BASE_URL }}/ideas/ideaDialog/{{ $id }}">
<div class="row">
<div class="col-md-8">
<input type="hidden" value="{{ $currentCanvas }}" name="canvasId"/>
<input type="hidden" value="{{ $tpl->escape($canvasItem['box']) }}" name="box" id="box"/>
<input type="hidden" value="{{ $id }}" name="itemId" id="itemId"/>
<input type="hidden" name="status" value="{{ $canvasItem['status'] }}" />
<input type="hidden" value="{{ $id }}" name="id" autocomplete="off" readonly/>
<input type="hidden" name="milestoneId" value="{{ $canvasItem['milestoneId'] }}"/>
<input type="hidden" name="changeItem" value="1"/>
<x-global::forms.text-input name="description" variant="headline" style="width:99%;" value="{{ $tpl->escape($canvasItem['description']) }}"
placeholder="{{ __('input.placeholders.short_name') }}" /><br/>
<input type="text" value="{{ $tpl->escape($canvasItem['tags']) }}" name="tags" id="tags" />
<textarea rows="3" cols="10" name="data" class="tiptapComplex"
placeholder="">{!! $tpl->escapeMinimal($canvasItem['data']) !!}</textarea><br/>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" id="primaryCanvasSubmitButton" />
<x-global::forms.button contentRole="secondary" inputType="submit" value="closeModal" id="saveAndClose">{!! __('buttons.save_and_close') !!}</x-global::forms.button>
@if ($id !== '')
<br/>
<hr>
<input type="hidden" name="comment" value="1"/>
<h4 class="widgettitle title-light"><span class="fa fa-submodules.generalComment"></span>{!! __('subtitles.discussion') !!}</h4>
@include('comments::submodules.generalComment', ['formUrl' => BASE_URL . '/ideas/ideaDialog/' . $id])
@endif
</div>
<div class="col-md-4">
@if ($id !== '')
<br/><br/>
<h4 class="widgettitle title-light"><span
class="fa fa-link"></span> {!! __('headlines.linked_milestone') !!} <i class="fa fa-question-circle-o helperTooltip" data-tippy-content="{{ __('tooltip.link_milestones_tooltip') }}"></i></h4>
<ul class="sortableTicketList" style="width:99%">
@if ($canvasItem['milestoneId'] == '')
<li class="ui-state-default center" id="milestone_0">
<h4>{!! __('headlines.no_milestone_link') !!}</h4>
{!! __('text.use_milestone_to_track_idea') !!}<br/>
<div class="row" id="milestoneSelectors">
@if ($login::userIsAtLeast($roles::$editor))
<div class="col-md-12">
<a href="javascript:void(0);"
onclick="leantime.ideasController.toggleMilestoneSelectors('new');">{!! __('links.create_link_milestone') !!}</a>
| <a href="javascript:void(0);"
onclick="leantime.ideasController.toggleMilestoneSelectors('existing');">{!! __('links.link_existing_milestone') !!}</a>
</div>
@endif
</div>
<div class="row" id="newMilestone" style="display:none;">
<div class="col-md-12">
<x-global::forms.textarea name="newMilestone"></x-global::forms.textarea><br/>
<input type="hidden" name="type" value="milestone"/>
<input type="hidden" name="leancanvasitemid" value="{{ $id }} "/>
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()"
contentRole="primary"/>
<a href="javascript:void(0);"
onclick="leantime.ideasController.toggleMilestoneSelectors('hide');">
<i class="fas fa-times"></i> {!! __('links.cancel') !!}
</a>
</div>
</div>
<div class="row" id="existingMilestone" style="display:none;">
<div class="col-md-12">
<select data-placeholder="{{ __('input.placeholders.filter_by_milestone') }}"
name="existingMilestone" class="user-select">
<option value="">{!! __('text.all_milestones') !!}</option>
@foreach ($milestones as $milestoneRow)
<option value="{{ $milestoneRow->id }}"
@if (isset($searchCriteria['milestone']) && ($searchCriteria['milestone'] == $milestoneRow->id))
selected='selected'
@endif
>{{ $tpl->escape($milestoneRow->headline) }}</option>
@endforeach
</select>
<input type="hidden" name="type" value="milestone"/>
<input type="hidden" name="leancanvasitemid" value="{{ $id }} "/>
<x-global::forms.button tag="input" inputType="button" :labelText="__('buttons.save')" onclick="jQuery('#primaryCanvasSubmitButton').click()"
contentRole="primary"/>
<a href="javascript:void(0);"
onclick="leantime.ideasController.toggleMilestoneSelectors('hide');">
<i class="fas fa-times"></i> {!! __('links.cancel') !!}
</a>
</div>
</div>
</li>
@else
<li class="ui-state-default" id="milestone_{{ $canvasItem['milestoneId'] }}"
class="leanCanvasMilestone">
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $canvasItem['milestoneId'] }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
<a href="{{ CURRENT_URL }}?removeMilestone={{ $canvasItem['milestoneId'] }}" class="ideaCanvasModal delete formModal"><i class="fa fa-close"></i> {!! __('links.remove') !!}</a>
</li>
@endif
</ul>
@endif
</div>
</div>
</form>
<div class="showDialogOnLoad" >
@if ($id != '')
<x-global::forms.button tag="a" link="{{ BASE_URL }}/ideas/delCanvasItem/{{ $id }}" class="ideaModal delete right" state="danger" variant="outline"><i
class="fa fa-trash"></i> {!! __('links.delete') !!}</x-global::forms.button>
@endif
</div>
@once
@push('scripts')
<script type="text/javascript">
window.onload = function () {
if (!window.jQuery) {
//It's not a modal
location.href = "{{ BASE_URL }}/ideas/showBoards?showIdeaModal={{ $canvasItem['id'] }}";
}
}
jQuery(document).ready(function(){
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initComplexEditor();
}
leantime.ticketsController.initTagsInput();
@if (!$login::userIsAtLeast($roles::$editor))
leantime.authController.makeInputReadonly(".nyroModalCont");
@endif
@if ($login::userHasRole([$roles::$commenter]))
leantime.submodules.generalCommentController.enableCommenterForms();
@endif
})
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,307 @@
@extends($layout)
@section('content')
@php
$allCanvas = $allCanvas ?? [];
$canvasTitle = '';
$canvasLabels = $canvasLabels ?? [];
// get canvas title
foreach ($allCanvas as $canvasRow) {
if ($canvasRow['id'] == ($currentCanvas ?? '')) {
$canvasTitle = $canvasRow['title'];
break;
}
}
@endphp
<div class="pageheader">
<div class="pageicon"><i class="far fa-lightbulb"></i></div>
<div class="pagetitle">
@if (count($allCanvas) > 0)
<x-global::subjectSwitcher
:parent="__('headlines.ideas')"
:current="$canvasTitle">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/ideas/boardDialog">{!! __('links.icon.create_new_board') !!}</a></li>
@endif
<li class="border"></li>
@foreach ($allCanvas as $canvasRow)
<li><a href='{{ BASE_URL }}/ideas/showBoards/{{ $canvasRow['id'] }}'>{{ $tpl->escape($canvasRow['title']) }}</a></li>
@endforeach
</x-global::subjectSwitcher>
@else
<h1>{!! __('headlines.ideas') !!}</h1>
@endif
</div>
@if (count($allCanvas) > 0)
<div class="pageheader-right">
<span class="dropdown dropdownWrapper headerEditDropdown">
<a href="javascript:void(0)" class="dropdown-toggle btn btn-transparent" data-toggle="dropdown"><i class="fa-solid fa-ellipsis-v"></i></a>
<ul class="dropdown-menu editCanvasDropdown ">
@if ($login::userIsAtLeast($roles::$editor))
<li><a href="#/ideas/boardDialog/{{ $currentCanvas }}">{!! __('links.icon.edit') !!}</a></li>
<li><a href="{{ BASE_URL }}/ideas/delCanvas/{{ $currentCanvas }}" class="delete">{!! __('links.icon.delete') !!}</a></li>
@endif
</ul>
</span>
</div>
@endif
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner" id="ideaBoards" style="min-height:350px;">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-4">
@if ($login::userIsAtLeast($roles::$editor))
@if (count($allCanvas) > 0)
<x-global::forms.button tag="a" link="#/ideas/ideaDialog?type=idea" contentRole="primary" id="customersegment"><span
class="far fa-lightbulb"></span>{!! __('buttons.add_idea') !!}</x-global::forms.button>
@endif
@endif
</div>
<div class="col-md-4 center">
</div>
<div class="col-md-4">
<div class="pull-right">
<div class="btn-group viewDropDown">
<button class="btn dropdown-toggle" data-toggle="dropdown">{!! __('buttons.idea_wall') !!} {!! __('links.view') !!}</button>
<ul class="dropdown-menu">
<li><a href="{{ BASE_URL }}/ideas/showBoards" class="active">{!! __('buttons.idea_wall') !!}</a></li>
<li><a href="{{ BASE_URL }}/ideas/advancedBoards" class="">{!! __('buttons.idea_kanban') !!}</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="clearfix"></div>
@if (count($allCanvas) > 0)
<div id="ideaMason" class="sortableTicketList" style="padding-top:10px;">
@foreach ($canvasItems as $row)
<div class="ticketBox" id="item_{{ $row['id'] }}" data-value="{{ $row['id'] }}">
<div class="row">
<div class="col-md-12">
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
&nbsp;&nbsp;&nbsp;
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.edit') !!}</li>
<li><a href="#/ideas/ideaDialog/{{ $row['id'] }}" class="" data="item_{{ $row['id'] }}"> {!! __('links.edit_canvas_item') !!}</a></li>
<li><a href="#/ideas/delCanvasItem/{{ $row['id'] }}" class="delete" data="item_{{ $row['id'] }}"> {!! __('links.delete_canvas_item') !!}</a></li>
</ul>
</div>
@endif
<h4><a href="#/ideas/ideaDialog/{{ $row['id'] }}"
data="item_{{ $row['id'] }}">{{ $tpl->escape($row['description']) }}</a></h4>
<div class="mainIdeaContent">
<div class="kanbanCardContent">
<div class="kanbanContent" style="margin-bottom: 20px; max-height:none;">
{!! $tpl->escapeMinimal($row['data']) !!}
</div>
</div>
</div>
<div class="clearfix" style="padding-bottom: 8px;"></div>
<div class="dropdown ticketDropdown statusDropdown show firstDropdown colorized">
<a class="dropdown-toggle f-left status {{ $canvasLabels[$row['box']]['class'] }} " href="javascript:void(0);" role="button" id="statusDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">{{ $canvasLabels[$row['box']]['name'] }}</span>
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu" aria-labelledby="statusDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_status') !!}</li>
@foreach ($canvasLabels as $key => $label)
{!! "<li class='dropdown-item'>
<a href='javascript:void(0);' class='" . $label['class'] . "' data-label='" . $tpl->escape($label['name']) . "' data-value='" . $row['id'] . '_' . $key . '_' . $label['class'] . "' id='ticketStatusChange" . $row['id'] . $key . "' >" . $tpl->escape($label['name']) . "</a></li>" !!}
@endforeach
</ul>
</div>
<div class="dropdown ticketDropdown userDropdown noBg show right lastDropdown dropRight">
<a class="dropdown-toggle f-left" href="javascript:void(0);" role="button" id="userDropdownMenuLink{{ $row['id'] }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="text">
@if ($row['authorFirstname'] != '')
{!! "<span id='userImage" . $row['id'] . "'><img src='" . BASE_URL . "/api/users?profileImage=" . $row['author'] . "' width='25' style='vertical-align: middle;'/></span><span id='user" . $row['id'] . "'></span>" !!}
@else
{!! "<span id='userImage" . $row['id'] . "'><img src='" . BASE_URL . "/api/users?profileImage=false' width='25' style='vertical-align: middle;'/></span><span id='user" . $row['id'] . "'></span>" !!}
@endif
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="userDropdownMenuLink{{ $row['id'] }}">
<li class="nav-header border">{!! __('dropdown.choose_user') !!}</li>
@foreach ($users as $user)
{!! "<li class='dropdown-item'>
<a href='javascript:void(0);' data-label='" . sprintf(__('text.full_name'), $tpl->escape($user['firstname']), $tpl->escape($user['lastname'])) . "' data-value='" . $row['id'] . '_' . $user['id'] . '_' . $user['profileId'] . "' id='userStatusChange" . $row['id'] . $user['id'] . "' ><img src='" . BASE_URL . "/api/users?profileImage=" . $user['id'] . "' width='25' style='vertical-align: middle; margin-right:5px;'/>" . sprintf(__('text.full_name'), $tpl->escape($user['firstname']), $tpl->escape($user['lastname'])) . "</a></li>" !!}
@endforeach
</ul>
</div>
<div class="pull-right" style="margin-right:10px;">
<a href="#/ideas/ideaDialog/{{ $row['id'] }}"
class="" data="item_{{ $row['id'] }}"
{!! $row['commentCount'] == 0 ? 'style="color: grey;"' : '' !!}>
<span class="fas fa-comments"></span></a> <small>{{ $row['commentCount'] }}</small>
</div>
</div>
</div>
@if ($row['milestoneHeadline'] != '')
<br/>
<div hx-trigger="load"
hx-indicator=".htmx-indicator"
hx-get="{{ BASE_URL }}/hx/tickets/milestones/showCard?milestoneId={{ $row['milestoneId'] }}">
<div class="htmx-indicator">
{!! __('label.loading_milestone') !!}
</div>
</div>
@endif
</div>
@endforeach
</div>
@if (count($canvasItems) == 0)
<div class='center'>
<div style='width:30%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_new_ideas_jdea.svg') !!}
</div>
<h3>{!! __('headlines.have_an_idea') !!}</h3><br />
{!! __('subtitles.start_collecting_ideas') !!}<br/><br/>
</div>
@endif
<div class="clearfix"></div>
@else
<br/><br/>
<div class='center'>
<div style='width:30%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_new_ideas_jdea.svg') !!}
</div>
<h3>{!! __('headlines.have_an_idea') !!}</h3><br />
{!! __('subtitles.start_collecting_ideas') !!}<br/><br/>
@if ($login::userIsAtLeast($roles::$editor))
<x-global::forms.button tag="a" link="javascript:void(0)"
class="addCanvasLink" contentRole="primary">{!! __('links.icon.create_new_board') !!}</x-global::forms.button>
@endif
</div>
@endif
<!-- Modals -->
<div class="modal fade bs-example-modal-lg" id="addCanvas">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form action="" method="post">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">{!! __('headlines.start_new_idea_board') !!}</h4>
</div>
<div class="modal-body">
<label>{!! __('label.topic_idea_board') !!}</label>
<x-global::forms.text-input name="canvastitle" placeholder="{{ __('input.placeholders.name_for_idea_board') }}"
style="width:90%" />
</div>
<div class="modal-footer">
<x-global::forms.button inputType="button" contentRole="tertiary"
data-dismiss="modal">{!! __('buttons.close') !!}</x-global::forms.button>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.create_board')" name="newCanvas"/>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<div class="modal fade bs-example-modal-lg" id="editCanvas">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form action="" method="post">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">{!! __('headlines.edit_board_name') !!}</h4>
</div>
<div class="modal-body">
<label>{!! __('label.title_idea_board') !!}</label>
<x-global::forms.text-input name="canvastitle" value="{{ $canvasTitle }}"
style="width:90%" />
</div>
<div class="modal-footer">
<x-global::forms.button inputType="button" contentRole="tertiary"
data-dismiss="modal">{!! __('buttons.close') !!}</x-global::forms.button>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="editCanvas"/>
</div>
</form>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<div class="clearfix"></div>
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function () {
leantime.ideasController.initMasonryWall();
leantime.ideasController.initBoardControlModal();
leantime.ideasController.initWallImageModals();
@if ($login::userIsAtLeast($roles::$editor))
leantime.ideasController.initStatusDropdown();
leantime.ideasController.initUserDropdown();
@else
leantime.authController.makeInputReadonly(".maincontentinner");
@endif
@if (isset($_GET['showIdeaModal']))
@php
if ($_GET['showIdeaModal'] == '') {
$modalUrl = '&type=idea';
} else {
$modalUrl = '/' . (int) $_GET['showIdeaModal'];
}
@endphp
leantime.ideasController.openModalManually("{{ BASE_URL }}/ideas/ideaDialog{{ $modalUrl }}");
window.history.pushState({}, document.title, '{{ BASE_URL }}/ideas/showBoards');
@endif
});
</script>
@endpush
@endonce
@endsection