feat: Add possibility to clone cards when cloning a board
Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Adjust BoardServiceTest to new dependencies Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Add BoardCloneModal vue component to frontend. Adjust BoardApi and store to support clone options Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Add license and credits Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Fix PHP code style Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Change default clone settings Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Add accordion for advanced settings Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Fix bug which caused board to be cloned when clicking out of the modal Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> Change wording of clone options Signed-off-by: Max Bachhuber <max.bachhuber@bahuma.io> fix: Rebase failures Signed-off-by: Julius Härtl <jus@bitgrid.net> update cloneBoards phpdoc make error message clear SPDX Header BoardCloneModal.vue Signed-off-by: grnd-alt <salimbelakkaf@outlook.de>
This commit is contained in:
committed by
Julius Knorr
parent
bdaf28eef4
commit
f2c30afe8a
@@ -5,6 +5,7 @@
|
|||||||
import { randUser } from '../utils/index.js'
|
import { randUser } from '../utils/index.js'
|
||||||
const user = randUser()
|
const user = randUser()
|
||||||
const recipient = randUser()
|
const recipient = randUser()
|
||||||
|
import { sampleBoard } from '../utils/sampleBoard'
|
||||||
|
|
||||||
describe('Board', function() {
|
describe('Board', function() {
|
||||||
|
|
||||||
@@ -58,3 +59,73 @@ describe('Board', function() {
|
|||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Board cloning', function() {
|
||||||
|
before(function() {
|
||||||
|
cy.createUser(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Clones a board without cards', function() {
|
||||||
|
const boardName = 'Clone board original'
|
||||||
|
const board = sampleBoard(boardName)
|
||||||
|
cy.createExampleBoard({ user, board }).then((board) => {
|
||||||
|
const boardId = board.id
|
||||||
|
cy.visit(`/apps/deck/board/${boardId}`)
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
|
||||||
|
.parent()
|
||||||
|
.find('button[aria-label="Actions"]')
|
||||||
|
.click()
|
||||||
|
cy.get('button:contains("Clone board")')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.modal-container button:contains("Clone")')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
|
||||||
|
.should('be.visible')
|
||||||
|
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + ' (copy)")')
|
||||||
|
.should('be.visible')
|
||||||
|
|
||||||
|
cy.get('.board-title h2').contains(boardName + ' (copy)')
|
||||||
|
|
||||||
|
cy.get('h3[aria-label="TestList"]')
|
||||||
|
.should('be.visible')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Clones a board with cards', function() {
|
||||||
|
const boardName = 'Clone with cards'
|
||||||
|
const board = sampleBoard(boardName)
|
||||||
|
cy.createExampleBoard({ user, board }).then((board) => {
|
||||||
|
const boardId = board.id
|
||||||
|
cy.visit(`/apps/deck/board/${boardId}`)
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
|
||||||
|
.parent()
|
||||||
|
.find('button[aria-label="Actions"]')
|
||||||
|
.click()
|
||||||
|
cy.get('button:contains("Clone board")')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.checkbox-content__text:contains("Clone cards")')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.modal-container button:contains("Clone")')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")')
|
||||||
|
.should('be.visible')
|
||||||
|
|
||||||
|
cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + ' (copy)")')
|
||||||
|
.should('be.visible')
|
||||||
|
|
||||||
|
cy.get('.board-title h2').contains(boardName + ' (copy)')
|
||||||
|
|
||||||
|
cy.get('h3[aria-label="TestList"]')
|
||||||
|
.should('be.visible')
|
||||||
|
|
||||||
|
cy.get('.card:contains("Hello world")')
|
||||||
|
.should('be.visible')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
19
docs/API.md
19
docs/API.md
@@ -423,6 +423,25 @@ A 403 response might be returned if the users ability to create new boards has b
|
|||||||
|
|
||||||
##### 200 Success
|
##### 200 Success
|
||||||
|
|
||||||
|
### POST /boards/{boardId}/clone - Clone a board
|
||||||
|
|
||||||
|
Creates a copy of the board.
|
||||||
|
|
||||||
|
#### Request body
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | ------ | ---------------------------------------------------- |
|
||||||
|
| withCards | Bool | Setting if the cards should be copied (Default: false) |
|
||||||
|
| withAssignments | Bool | Setting if the card assignments should be cloned (Default: false) |
|
||||||
|
| withLabels | Bool | Setting if the card labels should be cloned (Default: false) |
|
||||||
|
| withDueDate | Bool | Setting if the card due dates should be cloned (Default: false) |
|
||||||
|
| moveCardsToLeftStack | Bool | Setting if all cards should be moved to the most left column (useful for To-Do / Doing / Done boards) (Default: false) |
|
||||||
|
| restoreArchivedCards | Bool | Setting if the archived cards should be unarchived (Default: false) |
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
##### 200 Success
|
||||||
|
|
||||||
### DELETE /boards/{boardId}/acl/{aclId} - Delete an acl rule
|
### DELETE /boards/{boardId}/acl/{aclId} - Delete an acl rule
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|||||||
@@ -164,4 +164,13 @@ class BoardApiController extends ApiController {
|
|||||||
$acl = $this->boardService->deleteAcl($aclId);
|
$acl = $this->boardService->deleteAcl($aclId);
|
||||||
return new DataResponse($acl, HTTP::STATUS_OK);
|
return new DataResponse($acl, HTTP::STATUS_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
*/
|
||||||
|
public function clone(int $boardId, bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): DataResponse {
|
||||||
|
return new DataResponse(
|
||||||
|
$this->boardService->clone($boardId, $this->userId, $withCards, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,11 @@ class BoardController extends ApiController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @NoAdminRequired
|
* @NoAdminRequired
|
||||||
* @param $boardId
|
|
||||||
* @return Board
|
|
||||||
*/
|
*/
|
||||||
public function clone($boardId) {
|
public function clone(int $boardId, bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): DataResponse {
|
||||||
return $this->boardService->clone($boardId, $this->userId);
|
return new DataResponse(
|
||||||
|
$this->boardService->clone($boardId, $this->userId, $withCards, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
namespace OCA\Deck\Db;
|
namespace OCA\Deck\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method getTitle(): string
|
||||||
|
*/
|
||||||
class Label extends RelationalEntity {
|
class Label extends RelationalEntity {
|
||||||
protected $title;
|
protected $title;
|
||||||
protected $color;
|
protected $color;
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ class AssignmentService {
|
|||||||
$this->changeHelper = $changeHelper;
|
$this->changeHelper = $changeHelper;
|
||||||
$this->activityManager = $activityManager;
|
$this->activityManager = $activityManager;
|
||||||
$this->eventDispatcher = $eventDispatcher;
|
$this->eventDispatcher = $eventDispatcher;
|
||||||
|
|
||||||
$this->assignmentServiceValidator->check(compact('userId'));
|
|
||||||
$this->currentUser = $userId;
|
$this->currentUser = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use OCA\Deck\Db\AclMapper;
|
|||||||
use OCA\Deck\Db\AssignmentMapper;
|
use OCA\Deck\Db\AssignmentMapper;
|
||||||
use OCA\Deck\Db\Board;
|
use OCA\Deck\Db\Board;
|
||||||
use OCA\Deck\Db\BoardMapper;
|
use OCA\Deck\Db\BoardMapper;
|
||||||
|
use OCA\Deck\Db\Card;
|
||||||
use OCA\Deck\Db\CardMapper;
|
use OCA\Deck\Db\CardMapper;
|
||||||
use OCA\Deck\Db\ChangeHelper;
|
use OCA\Deck\Db\ChangeHelper;
|
||||||
use OCA\Deck\Db\IPermissionMapper;
|
use OCA\Deck\Db\IPermissionMapper;
|
||||||
@@ -29,6 +30,7 @@ use OCA\Deck\Event\AclCreatedEvent;
|
|||||||
use OCA\Deck\Event\AclDeletedEvent;
|
use OCA\Deck\Event\AclDeletedEvent;
|
||||||
use OCA\Deck\Event\AclUpdatedEvent;
|
use OCA\Deck\Event\AclUpdatedEvent;
|
||||||
use OCA\Deck\Event\BoardUpdatedEvent;
|
use OCA\Deck\Event\BoardUpdatedEvent;
|
||||||
|
use OCA\Deck\Event\CardCreatedEvent;
|
||||||
use OCA\Deck\NoPermissionException;
|
use OCA\Deck\NoPermissionException;
|
||||||
use OCA\Deck\Notification\NotificationHelper;
|
use OCA\Deck\Notification\NotificationHelper;
|
||||||
use OCA\Deck\Validators\BoardServiceValidator;
|
use OCA\Deck\Validators\BoardServiceValidator;
|
||||||
@@ -38,80 +40,37 @@ use OCP\DB\Exception as DbException;
|
|||||||
use OCP\EventDispatcher\IEventDispatcher;
|
use OCP\EventDispatcher\IEventDispatcher;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
use OCP\IGroupManager;
|
|
||||||
use OCP\IL10N;
|
use OCP\IL10N;
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\IUserManager;
|
|
||||||
use OCP\Server;
|
use OCP\Server;
|
||||||
use Psr\Container\ContainerExceptionInterface;
|
use Psr\Container\ContainerExceptionInterface;
|
||||||
use Psr\Container\NotFoundExceptionInterface;
|
use Psr\Container\NotFoundExceptionInterface;
|
||||||
|
|
||||||
class BoardService {
|
class BoardService {
|
||||||
private BoardMapper $boardMapper;
|
|
||||||
private StackMapper $stackMapper;
|
|
||||||
private LabelMapper $labelMapper;
|
|
||||||
private AclMapper $aclMapper;
|
|
||||||
private IConfig $config;
|
|
||||||
private IL10N $l10n;
|
|
||||||
private PermissionService $permissionService;
|
|
||||||
private NotificationHelper $notificationHelper;
|
|
||||||
private AssignmentMapper $assignedUsersMapper;
|
|
||||||
private IUserManager $userManager;
|
|
||||||
private IGroupManager $groupManager;
|
|
||||||
private ?string $userId;
|
|
||||||
private ActivityManager $activityManager;
|
|
||||||
private IEventDispatcher $eventDispatcher;
|
|
||||||
private ChangeHelper $changeHelper;
|
|
||||||
private CardMapper $cardMapper;
|
|
||||||
private ?array $boardsCacheFull = null;
|
private ?array $boardsCacheFull = null;
|
||||||
private ?array $boardsCachePartial = null;
|
private ?array $boardsCachePartial = null;
|
||||||
private IURLGenerator $urlGenerator;
|
|
||||||
private IDBConnection $connection;
|
|
||||||
private BoardServiceValidator $boardServiceValidator;
|
|
||||||
private SessionMapper $sessionMapper;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
BoardMapper $boardMapper,
|
private BoardMapper $boardMapper,
|
||||||
StackMapper $stackMapper,
|
private StackMapper $stackMapper,
|
||||||
CardMapper $cardMapper,
|
private CardMapper $cardMapper,
|
||||||
IConfig $config,
|
private IConfig $config,
|
||||||
IL10N $l10n,
|
private IL10N $l10n,
|
||||||
LabelMapper $labelMapper,
|
private LabelMapper $labelMapper,
|
||||||
AclMapper $aclMapper,
|
private AclMapper $aclMapper,
|
||||||
PermissionService $permissionService,
|
private PermissionService $permissionService,
|
||||||
NotificationHelper $notificationHelper,
|
private AssignmentService $assignmentService,
|
||||||
AssignmentMapper $assignedUsersMapper,
|
private NotificationHelper $notificationHelper,
|
||||||
IUserManager $userManager,
|
private AssignmentMapper $assignedUsersMapper,
|
||||||
IGroupManager $groupManager,
|
private ActivityManager $activityManager,
|
||||||
ActivityManager $activityManager,
|
private IEventDispatcher $eventDispatcher,
|
||||||
IEventDispatcher $eventDispatcher,
|
private ChangeHelper $changeHelper,
|
||||||
ChangeHelper $changeHelper,
|
private IURLGenerator $urlGenerator,
|
||||||
IURLGenerator $urlGenerator,
|
private IDBConnection $connection,
|
||||||
IDBConnection $connection,
|
private BoardServiceValidator $boardServiceValidator,
|
||||||
BoardServiceValidator $boardServiceValidator,
|
private SessionMapper $sessionMapper,
|
||||||
SessionMapper $sessionMapper,
|
private ?string $userId,
|
||||||
?string $userId,
|
|
||||||
) {
|
) {
|
||||||
$this->boardMapper = $boardMapper;
|
|
||||||
$this->stackMapper = $stackMapper;
|
|
||||||
$this->cardMapper = $cardMapper;
|
|
||||||
$this->labelMapper = $labelMapper;
|
|
||||||
$this->config = $config;
|
|
||||||
$this->aclMapper = $aclMapper;
|
|
||||||
$this->l10n = $l10n;
|
|
||||||
$this->permissionService = $permissionService;
|
|
||||||
$this->notificationHelper = $notificationHelper;
|
|
||||||
$this->assignedUsersMapper = $assignedUsersMapper;
|
|
||||||
$this->userManager = $userManager;
|
|
||||||
$this->groupManager = $groupManager;
|
|
||||||
$this->activityManager = $activityManager;
|
|
||||||
$this->eventDispatcher = $eventDispatcher;
|
|
||||||
$this->changeHelper = $changeHelper;
|
|
||||||
$this->userId = $userId;
|
|
||||||
$this->urlGenerator = $urlGenerator;
|
|
||||||
$this->connection = $connection;
|
|
||||||
$this->boardServiceValidator = $boardServiceValidator;
|
|
||||||
$this->sessionMapper = $sessionMapper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +109,7 @@ class BoardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -177,7 +136,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return bool
|
* @return bool
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -204,7 +163,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return bool
|
* @return bool
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -281,7 +240,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return Board
|
* @return Board
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -305,7 +264,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return \OCP\AppFramework\Db\Entity
|
* @return \OCP\AppFramework\Db\Entity
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
*/
|
*/
|
||||||
public function deleteUndo($id) {
|
public function deleteUndo($id) {
|
||||||
@@ -325,7 +284,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return \OCP\AppFramework\Db\Entity
|
* @return \OCP\AppFramework\Db\Entity
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -346,7 +305,7 @@ class BoardService {
|
|||||||
* @param $archived
|
* @param $archived
|
||||||
* @return \OCP\AppFramework\Db\Entity
|
* @return \OCP\AppFramework\Db\Entity
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -411,7 +370,7 @@ class BoardService {
|
|||||||
* @param $manage
|
* @param $manage
|
||||||
* @return \OCP\AppFramework\Db\Entity
|
* @return \OCP\AppFramework\Db\Entity
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
*/
|
*/
|
||||||
public function addAcl($boardId, $type, $participant, $edit, $share, $manage) {
|
public function addAcl($boardId, $type, $participant, $edit, $share, $manage) {
|
||||||
$this->boardServiceValidator->check(compact('boardId', 'type', 'participant', 'edit', 'share', 'manage'));
|
$this->boardServiceValidator->check(compact('boardId', 'type', 'participant', 'edit', 'share', 'manage'));
|
||||||
@@ -455,7 +414,7 @@ class BoardService {
|
|||||||
* @param $manage
|
* @param $manage
|
||||||
* @return \OCP\AppFramework\Db\Entity
|
* @return \OCP\AppFramework\Db\Entity
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -519,15 +478,16 @@ class BoardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $id
|
|
||||||
* @param $userId
|
|
||||||
* @return Board
|
|
||||||
* @throws DoesNotExistException
|
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
|
* @throws DbException
|
||||||
|
* @throws DoesNotExistException
|
||||||
|
* @throws MultipleObjectsReturnedException
|
||||||
|
* @throws NoPermissionException
|
||||||
*/
|
*/
|
||||||
public function clone($id, $userId) {
|
public function clone(
|
||||||
|
int $id, string $userId,
|
||||||
|
bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false,
|
||||||
|
): Board {
|
||||||
$this->boardServiceValidator->check(compact('id', 'userId'));
|
$this->boardServiceValidator->check(compact('id', 'userId'));
|
||||||
|
|
||||||
if (!$this->permissionService->canCreate()) {
|
if (!$this->permissionService->canCreate()) {
|
||||||
@@ -550,6 +510,16 @@ class BoardService {
|
|||||||
]);
|
]);
|
||||||
$this->boardMapper->insert($newBoard);
|
$this->boardMapper->insert($newBoard);
|
||||||
|
|
||||||
|
foreach ($this->aclMapper->findAll($board->getId()) as $acl) {
|
||||||
|
$this->addAcl($newBoard->getId(),
|
||||||
|
$acl->getType(),
|
||||||
|
$acl->getParticipant(),
|
||||||
|
$acl->getPermissionEdit(),
|
||||||
|
$acl->getPermissionShare(),
|
||||||
|
$acl->getPermissionManage());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$labels = $this->labelMapper->findAll($id);
|
$labels = $this->labelMapper->findAll($id);
|
||||||
foreach ($labels as $label) {
|
foreach ($labels as $label) {
|
||||||
$newLabel = new Label();
|
$newLabel = new Label();
|
||||||
@@ -572,6 +542,10 @@ class BoardService {
|
|||||||
$this->stackMapper->insert($newStack);
|
$this->stackMapper->insert($newStack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($withCards) {
|
||||||
|
$this->cloneCards($board, $newBoard, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->find($newBoard->getId());
|
return $this->find($newBoard->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +593,7 @@ class BoardService {
|
|||||||
* @param $id
|
* @param $id
|
||||||
* @return Board
|
* @return Board
|
||||||
* @throws DoesNotExistException
|
* @throws DoesNotExistException
|
||||||
* @throws \OCA\Deck\NoPermissionException
|
* @throws NoPermissionException
|
||||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
@@ -675,6 +649,83 @@ class BoardService {
|
|||||||
return $boards;
|
return $boards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cloneCards(Board $board, Board $newBoard, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): void {
|
||||||
|
$stacks = $this->stackMapper->findAll($board->getId());
|
||||||
|
$newStacks = $this->stackMapper->findAll($newBoard->getId());
|
||||||
|
|
||||||
|
$stackSorter = function (Stack $a, Stack $b) {
|
||||||
|
return $a->getOrder() - $b->getOrder();
|
||||||
|
};
|
||||||
|
usort($stacks, $stackSorter);
|
||||||
|
usort($newStacks, $stackSorter);
|
||||||
|
|
||||||
|
$i = 0;
|
||||||
|
foreach ($stacks as $stack) {
|
||||||
|
$cards = $this->cardMapper->findAll($stack->getId());
|
||||||
|
$archivedCards = $this->cardMapper->findAllArchived($stack->getId());
|
||||||
|
|
||||||
|
/** @var Card[] $cards */
|
||||||
|
$cards = array_merge($cards, $archivedCards);
|
||||||
|
|
||||||
|
foreach ($cards as $card) {
|
||||||
|
$targetStackId = $moveCardsToLeftStack ? $newStacks[0]->getId() : $newStacks[$i]->getId();
|
||||||
|
|
||||||
|
// Create a cloned card.
|
||||||
|
// Done with setters as only fields set via setters get written to db
|
||||||
|
$newCard = new Card();
|
||||||
|
$newCard->setTitle($card->getTitle());
|
||||||
|
$newCard->setDescription($card->getDescription());
|
||||||
|
$newCard->setStackId($targetStackId);
|
||||||
|
$newCard->setType($card->getType());
|
||||||
|
$newCard->setOwner($card->getOwner());
|
||||||
|
$newCard->setOrder($card->getOrder());
|
||||||
|
$newCard->setDuedate($withDueDate ? $card->getDuedate() : null);
|
||||||
|
$newCard->setArchived($restoreArchivedCards ? false : $card->getArchived());
|
||||||
|
$newCard->setStackId($targetStackId);
|
||||||
|
|
||||||
|
// Persist the cloned card.
|
||||||
|
$newCard = $this->cardMapper->insert($newCard);
|
||||||
|
|
||||||
|
|
||||||
|
// Copy labels.
|
||||||
|
if ($withLabels) {
|
||||||
|
$labels = $this->labelMapper->findAssignedLabelsForCard($card->getId());
|
||||||
|
$newLabels = $this->labelMapper->findAll($newBoard->getId());
|
||||||
|
$newLabelTitles = [];
|
||||||
|
foreach ($newLabels as $label) {
|
||||||
|
$newLabelTitles[$label->getTitle()] = $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$newLabelId = $newLabelTitles[$label->getTitle()]?->getId() ?? null;
|
||||||
|
if ($newLabelId) {
|
||||||
|
$this->cardMapper->assignLabel($newCard->getId(), $newLabelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Copy assignments.
|
||||||
|
if ($withAssignments) {
|
||||||
|
$assignments = $this->assignedUsersMapper->findAll($card->getId());
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
$this->assignmentService->assignUser($newCard->getId(), $assignment->getParticipant(), $assignment->getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known limitation: Currently we do not copy attachments or comments
|
||||||
|
|
||||||
|
// Copied from CardService because CardService cannot be injected due to cyclic dependencies.
|
||||||
|
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE);
|
||||||
|
$this->changeHelper->cardChanged($card->getId(), false);
|
||||||
|
$this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card));
|
||||||
|
}
|
||||||
|
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function enrichWithStacks($board, $since = -1) {
|
private function enrichWithStacks($board, $since = -1) {
|
||||||
$stacks = $this->stackMapper->findAll($board->getId(), null, null, $since);
|
$stacks = $this->stackMapper->findAll($board->getId(), null, null, $since);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
:force-display-actions="isTouchDevice"
|
:force-display-actions="isTouchDevice"
|
||||||
@click="onNavigate"
|
@click="onNavigate"
|
||||||
@undo="unDelete">
|
@undo="unDelete">
|
||||||
<NcAppNavigationIconBullet slot="icon" :color="board.color" />
|
<template #icon>
|
||||||
|
<NcAppNavigationIconBullet :color="board.color" />
|
||||||
|
<BoardCloneModal v-if="cloneModalOpen" :board-title="board.title" @close="onCloseCloneModal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #counter>
|
<template #counter>
|
||||||
<AccountIcon v-if="board.acl.length > 0" />
|
<AccountIcon v-if="board.acl.length > 0" />
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
</NcActionButton>
|
</NcActionButton>
|
||||||
<NcActionButton v-if="canCreate && !board.archived"
|
<NcActionButton v-if="canCreate && !board.archived"
|
||||||
:close-after-click="true"
|
:close-after-click="true"
|
||||||
@click="actionClone">
|
@click="showCloneModal">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CloneIcon :size="20" decorative />
|
<CloneIcon :size="20" decorative />
|
||||||
</template>
|
</template>
|
||||||
@@ -157,6 +160,7 @@ import { loadState } from '@nextcloud/initial-state'
|
|||||||
import { emit } from '@nextcloud/event-bus'
|
import { emit } from '@nextcloud/event-bus'
|
||||||
|
|
||||||
import isTouchDevice from '../../mixins/isTouchDevice.js'
|
import isTouchDevice from '../../mixins/isTouchDevice.js'
|
||||||
|
import BoardCloneModal from './BoardCloneModal.vue'
|
||||||
|
|
||||||
const canCreateState = loadState('deck', 'canCreate')
|
const canCreateState = loadState('deck', 'canCreate')
|
||||||
|
|
||||||
@@ -174,6 +178,7 @@ export default {
|
|||||||
CloneIcon,
|
CloneIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
BoardCloneModal,
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
ClickOutside,
|
ClickOutside,
|
||||||
@@ -201,6 +206,7 @@ export default {
|
|||||||
isDueSubmenuActive: false,
|
isDueSubmenuActive: false,
|
||||||
updateDueSetting: null,
|
updateDueSetting: null,
|
||||||
canCreate: canCreateState,
|
canCreate: canCreateState,
|
||||||
|
cloneModalOpen: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -349,6 +355,26 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showCloneModal() {
|
||||||
|
this.cloneModalOpen = true
|
||||||
|
},
|
||||||
|
async onCloseCloneModal(data) {
|
||||||
|
this.cloneModalOpen = false
|
||||||
|
if (data) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const newBoard = await this.$store.dispatch('cloneBoard', {
|
||||||
|
boardData: this.board,
|
||||||
|
settings: data,
|
||||||
|
})
|
||||||
|
this.loading = false
|
||||||
|
this.$router.push({ name: 'board', params: { id: newBoard.id } })
|
||||||
|
} catch (e) {
|
||||||
|
OC.Notification.showTemporary(t('deck', 'An error occurred'))
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
124
src/components/navigation/BoardCloneModal.vue
Normal file
124
src/components/navigation/BoardCloneModal.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!--
|
||||||
|
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<NcDialog :name="t('deck', 'Clone {boardTitle}', {boardTitle: boardTitle})" :show="true" @close="close(false)">
|
||||||
|
<div class="modal__content">
|
||||||
|
<NcCheckboxRadioSwitch :checked.sync="withCards">
|
||||||
|
{{ t('deck', 'Clone cards') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<NcCheckboxRadioSwitch v-if="withCards" :checked.sync="withAssignments">
|
||||||
|
{{ t('deck', 'Clone assignments') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<NcCheckboxRadioSwitch v-if="withCards" :checked.sync="withLabels">
|
||||||
|
{{ t('deck', 'Clone labels') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<NcCheckboxRadioSwitch v-if="withCards" :checked.sync="withDueDate">
|
||||||
|
{{ t('deck', 'Clone due dates') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<div v-if="withCards" class="accordion" :class="{ 'is-open': accordionOpen }">
|
||||||
|
<div class="accordion__toggle" @click="accordionOpen = !accordionOpen">
|
||||||
|
<span class="accordion__toggle__icon">
|
||||||
|
‣
|
||||||
|
</span>
|
||||||
|
{{ t('deck', 'Advanced options') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="accordionOpen" class="accordion__content">
|
||||||
|
<NcCheckboxRadioSwitch v-if="withCards" :checked.sync="moveCardsToLeftStack">
|
||||||
|
{{ t('deck', 'Move all cards to the first list') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
<NcCheckboxRadioSwitch v-if="withCards" :checked.sync="restoreArchivedCards">
|
||||||
|
{{ t('deck', 'Restore archived cards') }}
|
||||||
|
</NcCheckboxRadioSwitch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<NcButton @click="cancel">
|
||||||
|
{{ t('deck', 'Cancel') }}
|
||||||
|
</NcButton>
|
||||||
|
<NcButton type="primary" @click="save">
|
||||||
|
{{ t('deck', 'Clone') }}
|
||||||
|
</NcButton>
|
||||||
|
</template>
|
||||||
|
</NcDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'BoardCloneModal',
|
||||||
|
components: {
|
||||||
|
NcDialog,
|
||||||
|
NcCheckboxRadioSwitch,
|
||||||
|
NcButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
boardTitle: {
|
||||||
|
type: String,
|
||||||
|
default: 'Board',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
withCards: false,
|
||||||
|
withAssignments: true,
|
||||||
|
withLabels: true,
|
||||||
|
withDueDate: true,
|
||||||
|
moveCardsToLeftStack: false,
|
||||||
|
restoreArchivedCards: false,
|
||||||
|
accordionOpen: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
close(data) {
|
||||||
|
this.$emit('close', data)
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
const data = {
|
||||||
|
withCards: this.withCards,
|
||||||
|
withAssignments: this.withAssignments,
|
||||||
|
withLabels: this.withLabels,
|
||||||
|
withDueDate: this.withDueDate,
|
||||||
|
moveCardsToLeftStack: this.moveCardsToLeftStack,
|
||||||
|
restoreArchivedCards: this.restoreArchivedCards,
|
||||||
|
}
|
||||||
|
this.close(data)
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
this.close(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal__content {
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__buttons {
|
||||||
|
text-align: end;
|
||||||
|
margin-top: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__toggle {
|
||||||
|
margin: .5em 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion__toggle__icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.is-open .accordion__toggle__icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -123,9 +123,16 @@ export class BoardApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async cloneBoard(board) {
|
async cloneBoard(board, withCards = false, withAssignments = false, withLabels = false, withDueDate = false, moveCardsToLeftStack = false, restoreArchivedCards = false) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(this.url(`/boards/${board.id}/clone`))
|
const response = await axios.post(this.url(`/boards/${board.id}/clone`), {
|
||||||
|
withCards,
|
||||||
|
withAssignments,
|
||||||
|
withLabels,
|
||||||
|
withDueDate,
|
||||||
|
moveCardsToLeftStack,
|
||||||
|
restoreArchivedCards,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -398,9 +398,11 @@ export default new Vuex.Store({
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async cloneBoard({ commit }, boardData) {
|
async cloneBoard({ commit }, { boardData, settings }) {
|
||||||
|
const { withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards } = settings
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newBoard = await apiClient.cloneBoard(boardData)
|
const newBoard = await apiClient.cloneBoard(boardData, withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards)
|
||||||
commit('cloneBoard', newBoard)
|
commit('cloneBoard', newBoard)
|
||||||
return newBoard
|
return newBoard
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -46,10 +46,8 @@ use OCA\Deck\Validators\BoardServiceValidator;
|
|||||||
use OCP\EventDispatcher\IEventDispatcher;
|
use OCP\EventDispatcher\IEventDispatcher;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
use OCP\IGroupManager;
|
|
||||||
use OCP\IURLGenerator;
|
use OCP\IURLGenerator;
|
||||||
use OCP\IUser;
|
use OCP\IUser;
|
||||||
use OCP\IUserManager;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use Test\TestCase;
|
use Test\TestCase;
|
||||||
|
|
||||||
@@ -73,14 +71,12 @@ class BoardServiceTest extends TestCase {
|
|||||||
private $cardMapper;
|
private $cardMapper;
|
||||||
/** @var PermissionService */
|
/** @var PermissionService */
|
||||||
private $permissionService;
|
private $permissionService;
|
||||||
|
/** @var AssignmentService */
|
||||||
|
private $assignmentService;
|
||||||
/** @var NotificationHelper */
|
/** @var NotificationHelper */
|
||||||
private $notificationHelper;
|
private $notificationHelper;
|
||||||
/** @var AssignmentMapper */
|
/** @var AssignmentMapper */
|
||||||
private $assignedUsersMapper;
|
private $assignedUsersMapper;
|
||||||
/** @var IUserManager */
|
|
||||||
private $userManager;
|
|
||||||
/** @var IUserManager */
|
|
||||||
private $groupManager;
|
|
||||||
/** @var ActivityManager */
|
/** @var ActivityManager */
|
||||||
private $activityManager;
|
private $activityManager;
|
||||||
/** @var ChangeHelper */
|
/** @var ChangeHelper */
|
||||||
@@ -103,14 +99,13 @@ class BoardServiceTest extends TestCase {
|
|||||||
$this->aclMapper = $this->createMock(AclMapper::class);
|
$this->aclMapper = $this->createMock(AclMapper::class);
|
||||||
$this->boardMapper = $this->createMock(BoardMapper::class);
|
$this->boardMapper = $this->createMock(BoardMapper::class);
|
||||||
$this->stackMapper = $this->createMock(StackMapper::class);
|
$this->stackMapper = $this->createMock(StackMapper::class);
|
||||||
$this->config = $this->createMock(IConfig::class);
|
|
||||||
$this->cardMapper = $this->createMock(CardMapper::class);
|
$this->cardMapper = $this->createMock(CardMapper::class);
|
||||||
|
$this->config = $this->createMock(IConfig::class);
|
||||||
$this->labelMapper = $this->createMock(LabelMapper::class);
|
$this->labelMapper = $this->createMock(LabelMapper::class);
|
||||||
$this->permissionService = $this->createMock(PermissionService::class);
|
$this->permissionService = $this->createMock(PermissionService::class);
|
||||||
|
$this->assignmentService = $this->createMock(AssignmentService::class);
|
||||||
$this->notificationHelper = $this->createMock(NotificationHelper::class);
|
$this->notificationHelper = $this->createMock(NotificationHelper::class);
|
||||||
$this->assignedUsersMapper = $this->createMock(AssignmentMapper::class);
|
$this->assignedUsersMapper = $this->createMock(AssignmentMapper::class);
|
||||||
$this->userManager = $this->createMock(IUserManager::class);
|
|
||||||
$this->groupManager = $this->createMock(IGroupManager::class);
|
|
||||||
$this->activityManager = $this->createMock(ActivityManager::class);
|
$this->activityManager = $this->createMock(ActivityManager::class);
|
||||||
$this->changeHelper = $this->createMock(ChangeHelper::class);
|
$this->changeHelper = $this->createMock(ChangeHelper::class);
|
||||||
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
|
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
|
||||||
@@ -128,10 +123,9 @@ class BoardServiceTest extends TestCase {
|
|||||||
$this->labelMapper,
|
$this->labelMapper,
|
||||||
$this->aclMapper,
|
$this->aclMapper,
|
||||||
$this->permissionService,
|
$this->permissionService,
|
||||||
|
$this->assignmentService,
|
||||||
$this->notificationHelper,
|
$this->notificationHelper,
|
||||||
$this->assignedUsersMapper,
|
$this->assignedUsersMapper,
|
||||||
$this->userManager,
|
|
||||||
$this->groupManager,
|
|
||||||
$this->activityManager,
|
$this->activityManager,
|
||||||
$this->eventDispatcher,
|
$this->eventDispatcher,
|
||||||
$this->changeHelper,
|
$this->changeHelper,
|
||||||
@@ -157,12 +151,6 @@ class BoardServiceTest extends TestCase {
|
|||||||
->method('findAllForUser')
|
->method('findAllForUser')
|
||||||
->with('admin')
|
->with('admin')
|
||||||
->willReturn([$b1, $b2, $b3]);
|
->willReturn([$b1, $b2, $b3]);
|
||||||
$user = $this->createMock(IUser::class);
|
|
||||||
$this->groupManager->method('getUserGroupIds')
|
|
||||||
->willReturn(['a', 'b', 'c']);
|
|
||||||
$this->userManager->method('get')
|
|
||||||
->with($this->userId)
|
|
||||||
->willReturn($user);
|
|
||||||
|
|
||||||
$result = $this->service->findAll();
|
$result = $this->service->findAll();
|
||||||
sort($result);
|
sort($result);
|
||||||
|
|||||||
Reference in New Issue
Block a user